From d1f3ad891a2c3149b18520d9efeacc31e942d176 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 19 Oct 2026 10:31:49 -0600 Subject: [PATCH] P2 slice 4: access Agent tokens (wb_ and 64 hex digits), one book and one name each, kept only as SHA-256 in a chain log and in the OS keyring; made, listed and revoked only through the admin socket (`wanderbooks token ...`). The door checks them, scopes cover every tool, every call is in the access log (fail closed), and the owner passphrase is an Argon2id hash. Setup's terminal test waits for its prompts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 149 ++++- Cargo.toml | 8 + crates/ledger-cli/src/main.rs | 66 +- crates/ledger-cli/tests/local_server.rs | 722 ++++++++++++++++++++- crates/ledger-cli/tests/open_while_held.rs | 125 +++- crates/ledger-cli/tests/setup.rs | 153 +++++ crates/ledger-host/Cargo.toml | 10 + crates/ledger-host/src/bridge.rs | 170 ++++- crates/ledger-host/src/commands.rs | 340 +++++++++- crates/ledger-host/src/knobs.rs | 27 +- crates/ledger-host/src/lib.rs | 4 +- crates/ledger-host/src/tokens.rs | 266 ++++++++ crates/ledger-mcp/Cargo.toml | 3 + crates/ledger-mcp/src/access.rs | 183 ++++++ crates/ledger-mcp/src/admin.rs | 172 ++++- crates/ledger-mcp/src/lib.rs | 2 + crates/ledger-mcp/src/local.rs | 434 ++++++++++++- crates/ledger-mcp/src/scopes.rs | 359 ++++++++++ crates/ledger-mcp/src/server.rs | 344 +++++++++- crates/ledger-mcp/src/tools/access.rs | 114 ++++ crates/ledger-store/Cargo.toml | 3 + crates/ledger-store/src/access.rs | 484 ++++++++++++++ crates/ledger-store/src/auth.rs | 618 ++++++++++++++++++ crates/ledger-store/src/chain.rs | 508 +++++++++++++++ crates/ledger-store/src/lib.rs | 4 + crates/ledger-store/src/passphrase.rs | 304 +++++++++ crates/ledger-store/src/root.rs | 10 + plan/served.md | 38 +- 28 files changed, 5504 insertions(+), 116 deletions(-) create mode 100644 crates/ledger-host/src/tokens.rs create mode 100644 crates/ledger-mcp/src/access.rs create mode 100644 crates/ledger-mcp/src/scopes.rs create mode 100644 crates/ledger-mcp/src/tools/access.rs create mode 100644 crates/ledger-store/src/access.rs create mode 100644 crates/ledger-store/src/auth.rs create mode 100644 crates/ledger-store/src/chain.rs create mode 100644 crates/ledger-store/src/passphrase.rs diff --git a/Cargo.lock b/Cargo.lock index 3967ade..76912f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,18 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -596,6 +608,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "biblatex" version = "0.12.0" @@ -683,6 +701,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -702,6 +729,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -899,6 +935,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.7.0" @@ -2936,6 +2981,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -3395,6 +3449,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding", "hybrid-array", ] @@ -3644,6 +3699,15 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3835,16 +3899,20 @@ name = "ledger-host" version = "0.1.0" dependencies = [ "base64 0.22.1", + "keyring-core", "ledger-core", "ledger-mcp", "ledger-store", "libc", "reqwest", + "rpassword", "serde", "serde_json", "tempfile", "tokio", "tokio-util", + "zbus-secret-service-keyring-store", + "zeroize", ] [[package]] @@ -3875,6 +3943,7 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "futures-util", "http-body-util", "jsonschema", "ledger-config", @@ -3891,6 +3960,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "tokio", "tokio-util", @@ -3904,8 +3974,10 @@ dependencies = [ name = "ledger-store" version = "0.1.0" dependencies = [ + "argon2", "chrono", "criterion", + "getrandom 0.2.17", "hex", "ledger-core", "libc", @@ -3915,6 +3987,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "subtle", "tempfile", "thiserror 2.0.21", ] @@ -4872,6 +4945,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -4925,6 +5008,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.11.3" @@ -5399,7 +5493,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5866,6 +5960,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rqrr" version = "0.11.0" @@ -5876,6 +5981,16 @@ dependencies = [ "lru", ] +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -6017,7 +6132,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6136,6 +6251,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.0", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -8978,6 +9112,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.19.0" diff --git a/Cargo.toml b/Cargo.toml index ef808a9..1798317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,11 @@ repository = "https://git.swaits.com/swaits/wanderbooks" missing_docs = "warn" # `cargo kani` sets cfg(kani) for the proof harnesses. unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] } + +# Argon2id at the plan's 64 MiB, three passes, takes seconds unoptimized: +# its crates are built optimized in debug and test builds too. +[profile.dev.package.argon2] +opt-level = 3 + +[profile.dev.package.blake2] +opt-level = 3 diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 82a5f3d..2220981 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -395,7 +395,11 @@ fn print_usage() { eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]"); eprintln!(" wanderbooks setup [--root PATH] [--home DIR]"); eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID"); - eprintln!(" wanderbooks mcp --book ID"); + eprintln!( + " wanderbooks token create --book ID | list | revoke --book ID | print --book ID" + ); + eprintln!(" wanderbooks mcp --book ID [--token NAME]"); + eprintln!(" wanderbooks passphrase"); eprintln!(" wanderbooks serve | close | doctor"); eprintln!(); eprintln!(" new make a books directory and start the agent in it (alias: init)"); @@ -403,7 +407,9 @@ fn print_usage() { eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)"); eprintln!(" setup make the books' private folder (only your account can open it)"); eprintln!(" books list, start or adopt books (`books --help`)"); + eprintln!(" token make, list or revoke an agent's token for books (`token --help`)"); eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)"); + eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)"); eprintln!(" serve hold the books open until Ctrl-C"); eprintln!(" close close the books now: everything holding them lets go"); eprintln!(" doctor whether the books are open, what holds them, what went wrong"); @@ -864,7 +870,7 @@ fn run_setup(args: Vec) -> i32 { None }; match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) { - Ok((_, report)) => { + Ok((found, report)) => { let what = if report.created { "made" } else if report.tightened { @@ -876,7 +882,7 @@ fn run_setup(args: Vec) -> i32 { if report.recorded { println!("It isn't in the default place, so where it is was noted for next time."); } - 0 + owner_passphrase(&found) } Err(e) => { eprintln!("error: {e}"); @@ -885,6 +891,51 @@ fn run_setup(args: Vec) -> i32 { } } +/// After setup: the owner passphrase, asked for at a terminal if none is +/// set yet; elsewhere (a script, an agent) only said how to set it. +#[cfg(unix)] +fn owner_passphrase(root: &ledger_store::root::Root) -> i32 { + let auth = match root.auth_dir() { + Ok(dir) => dir, + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + }; + match ledger_store::passphrase::is_set(&auth) { + Ok(true) => return 0, + Ok(false) => {} + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + } + if !std::io::IsTerminal::is_terminal(&std::io::stdin()) { + println!("No owner passphrase yet: set one in a terminal with `wanderbooks passphrase`."); + return 0; + } + println!( + "Now the owner passphrase: what the books ask you for before anything only you may do." + ); + match ledger_host::commands::set_passphrase_at_terminal(&auth) { + Ok(()) => { + println!("The owner passphrase is set."); + 0 + } + Err(why) => { + eprintln!( + "error: {why}. The folder is made; set the passphrase with `wanderbooks passphrase`." + ); + 1 + } + } +} + +#[cfg(not(unix))] +fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 { + 0 +} + /// `wanderbooks dev-stdio`: the books server over stdio, in this process. fn run_dev_stdio(args: Vec) -> i32 { match ledger_mcp::run("wanderbooks dev-stdio", args) { @@ -927,6 +978,8 @@ fn run_local(mode: &str, args: Vec) -> i32 { "serve" => ledger_host::commands::serve(&prog, args), "close" => ledger_host::commands::close(&prog, args), "doctor" => ledger_host::commands::doctor(&prog, args), + "token" => ledger_host::commands::token(&prog, args), + "passphrase" => ledger_host::commands::passphrase(&prog, args), _ => ledger_host::commands::books(&prog, args), }; match result { @@ -963,7 +1016,10 @@ fn main() { Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())), Some("setup") => std::process::exit(run_setup(all.collect())), Some("server") => std::process::exit(run_server(all.collect())), - Some(mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor")) => { + Some( + mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor" | "token" + | "passphrase"), + ) => { let mode = mode.to_string(); std::process::exit(run_local(&mode, all.collect())) } @@ -999,7 +1055,7 @@ fn main() { } if let Some( mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve" - | "close" | "books" | "doctor"), + | "close" | "books" | "doctor" | "token" | "passphrase"), ) = cmd.as_deref() { eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]"); diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index 45b41ba..99e90c8 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -22,6 +22,8 @@ type Client = RunningService; /// A home with a data root made by `wanderbooks setup`. struct Home { dir: tempfile::TempDir, + /// A token for each book, made on first use (all scopes). + tokens: std::sync::Mutex>, } impl Home { @@ -37,7 +39,32 @@ impl Home { .output() .unwrap(); assert!(out.status.success(), "{out:?}"); - Self { dir } + Self { + dir, + tokens: Default::default(), + } + } + + /// The token tests use for `book`, made through the admin socket the + /// first time. + fn token(&self, book: &str) -> String { + let mut tokens = self.tokens.lock().unwrap(); + if let Some(t) = tokens.get(book) { + return t.clone(); + } + let text = match self.admin(Request::TokenCreate { + book: book.into(), + name: "test".into(), + scopes: ["read", "write", "post", "admin"] + .map(String::from) + .to_vec(), + replace: true, + }) { + Response::TokenCreated { text, .. } => text.0, + other => panic!("{other:?}"), + }; + tokens.insert(book.to_string(), text.clone()); + text } fn root(&self) -> PathBuf { @@ -130,22 +157,45 @@ impl Drop for Server { /// An HTTP client for the server's MCP socket. fn mcp_http(home: &Home) -> reqwest::Client { + mcp_http_builder(home).build().unwrap() +} + +fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder { // The short path is good in this process only, while it lives: kept. let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap(); let path = short.path().to_path_buf(); std::mem::forget(short); - reqwest::Client::builder() - .unix_socket(path) + reqwest::Client::builder().unix_socket(path) +} + +/// An HTTP client for the server's MCP socket that sends `book`'s token +/// on every request. +fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", home.token(book)).parse().unwrap(), + ); + mcp_http_builder(home) + .default_headers(headers) .build() .unwrap() } async fn connect(home: &Home, book: &str) -> Result { + let token = home.token(book); + connect_with(home, book, Some(token)).await +} + +/// [`connect`], with this token (or none). +async fn connect_with(home: &Home, book: &str, token: Option) -> Result { let http = mcp_http(home); - let transport = StreamableHttpClientTransport::with_client( - http, - StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")), - ); + let mut config = + StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")); + if let Some(token) = token { + config = config.auth_header(token); + } + let transport = StreamableHttpClientTransport::with_client(http, config); ClientConfig::default() .serve(transport) .await @@ -266,7 +316,9 @@ async fn every_book_is_served_on_its_own_route() { home.assert_hidden(&start.to_string()); assert!(start.to_string().contains("EUR"), "{start}"); // A route for no books says so, and names no folder. - let err = connect(&home, "nobody").await.unwrap_err(); + let err = connect_with(&home, "nobody", Some(home.token("acme"))) + .await + .unwrap_err(); home.assert_hidden(&err); drop((acme, beta)); assert!(server.close().success()); @@ -714,7 +766,7 @@ async fn a_call_whose_client_hung_up_finishes_before_the_close() { let home = Home::new("hangup"); let mut server = Server::start(&home); home.create("acme", "USD"); - let http = mcp_http(&home); + let http = mcp_http_for(&home, "acme"); let session = raw_session(&http, "acme").await; // A large document: its filing takes long enough to close mid-way. let mut bytes = b"%PDF-1.4\n".to_vec(); @@ -1063,3 +1115,655 @@ fn adoption_checks_the_vault_key_and_the_settings_file() { assert!(book.join("vault/tin-1.bin").is_file()); assert!(server.close().success()); } + +// P2 slice 4: tokens, scopes and the access log (local.rs INVARIANT 8, +// ledger-mcp access.rs, scopes.rs; ledger-store auth.rs, access.rs). + +impl Home { + /// A token for `book` named `name` with `scopes`, made (or remade). + fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String { + match self.admin(Request::TokenCreate { + book: book.into(), + name: name.into(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + replace: true, + }) { + Response::TokenCreated { text, .. } => text.0, + other => panic!("{other:?}"), + } + } + + fn book_dir(&self, book: &str) -> PathBuf { + self.root().join("books").join(book) + } +} + +/// A raw initialize on `book`'s route with `bearer` (if any): its status, +/// its refusal header, its session id and its body. +async fn raw_initialize( + home: &Home, + book: &str, + bearer: Option<&str>, +) -> (u16, Option, Option, String) { + let mut r = mcp_http(home) + .post(format!("http://localhost/b/{book}/mcp")) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body( + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "raw", "version": "0"}}}) + .to_string(), + ); + if let Some(b) = bearer { + r = r.bearer_auth(b); + } + let r = r.send().await.unwrap(); + let header = |name: &str| { + r.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + }; + let refused = header("x-wanderbooks-refused"); + let session = header("mcp-session-id"); + let status = r.status().as_u16(); + if status == 401 { + assert_eq!( + header("www-authenticate").as_deref(), + Some("Bearer realm=\"wanderbooks\"") + ); + } + (status, refused, session, r.text().await.unwrap()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_token_opens_only_its_own_books_until_revoked() { + let home = Home::new("tokens"); + let server = Server::start(&home); + home.create("acme", "USD"); + home.create("beta", "USD"); + // None, or one that isn't a token: 401, recorded, nothing named. + for bearer in [None, Some("wb_nonsense"), Some("")] { + let (status, refused, session, body) = raw_initialize(&home, "acme", bearer).await; + assert_eq!(status, 401, "{bearer:?}: {body}"); + assert_eq!(refused.as_deref(), Some("unauthorized")); + assert!(session.is_none()); + assert!(body.contains("token"), "{body}"); + home.assert_hidden(&body); + } + // Another book's token: 403. + let beta = home.token("beta"); + let (status, refused, _, body) = raw_initialize(&home, "acme", Some(&beta)).await; + assert_eq!(status, 403, "{body}"); + assert_eq!(refused.as_deref(), Some("wrong_books")); + assert!(body.contains("beta"), "{body}"); + // Its own: in. + let acme = connect(&home, "acme").await.unwrap(); + chart(&acme).await; + // Revoked: the very next call is refused, on the same session. + match home.admin(Request::TokenRevoke { + book: "acme".into(), + name: "test".into(), + id: None, + }) { + Response::TokenRevoked(t) => assert_eq!(t.name, "test"), + other => panic!("{other:?}"), + } + assert!(call(&acme, "books.chart", json!({})).await.is_err()); + let (status, ..) = raw_initialize(&home, "acme", Some(&home.token("acme"))).await; + assert_eq!(status, 401, "the remembered token was revoked"); + // The tokens listed carry no text. + match home.admin(Request::TokenList) { + Response::Tokens(tokens) => { + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].book, "beta"); + let text = serde_json::to_string(&tokens).unwrap(); + assert!(!text.contains(&beta[3..])); + } + other => panic!("{other:?}"), + } + // Every refusal at the door is in acme's access log. + let (records, _) = ledger_store::access::read(&home.book_dir("acme")).unwrap(); + let refused: Vec<&str> = records + .iter() + .filter_map(|r| match &r.access.result { + ledger_store::access::Outcome::Refused(code) => Some(code.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + refused, + [ + "unauthorized", + "unauthorized", + "unauthorized", + "wrong_books", + "unauthorized", + "unauthorized" + ], + "{records:?}" + ); + drop(acme); + assert!(server.close().success()); +} + +/// The tool names a client is offered. +async fn offered(c: &Client) -> Vec { + c.list_all_tools() + .await + .unwrap() + .into_iter() + .map(|t| t.name.to_string()) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_token_does_only_what_its_scopes_allow() { + let home = Home::new("scopes"); + let server = Server::start(&home); + home.create("acme", "USD"); + let full = connect(&home, "acme").await.unwrap(); + chart(&full).await; + let entry = call( + &full, + "books.draft_entry", + json!({"date": "2026-03-01", "description": "lunch"}), + ) + .await + .unwrap()["entry_id"] + .as_u64() + .unwrap(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + call( + &full, + "books.add_line", + json!({"entry_id": entry, "account": account, "side": side, "amount": "5.00"}), + ) + .await + .unwrap(); + } + let head = || ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap(); + let before = head().head_hash().to_string(); + // Read only. + let read = home.token_with("acme", "reader", &["read"]); + let reader = connect_with(&home, "acme", Some(read)).await.unwrap(); + let tools = offered(&reader).await; + assert!(tools.contains(&"books.chart".to_string())); + for hidden in ["books.add_line", "books.post_entry", "books.access_log"] { + assert!(!tools.contains(&hidden.to_string()), "{hidden} offered"); + } + assert!(call(&reader, "books.chart", json!({})).await.is_ok()); + let err = call( + &reader, + "books.add_account", + json!({"code": "1100", "name": "Bank", "account_type": "asset"}), + ) + .await + .unwrap_err(); + assert!(err.contains("needs books:write"), "{err}"); + assert!(err.contains("reader"), "{err}"); + // Write: a preview, never the post. + let write = home.token_with("acme", "writer", &["read", "write"]); + let writer = connect_with(&home, "acme", Some(write)).await.unwrap(); + let tools = offered(&writer).await; + assert!( + tools.contains(&"books.post_entry".to_string()), + "it may preview" + ); + assert!(!tools.contains(&"books.create_invoice".to_string())); + let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await; + if let Err(e) = &preview { + assert!( + !e.contains("needs books"), + "a preview is refused by scope: {e}" + ); + } + let err = call( + &writer, + "books.post_entry", + json!({"entry_id": entry, "confirmed": true}), + ) + .await + .unwrap_err(); + assert!(err.contains("needs books:post"), "{err}"); + assert_eq!(head().head_hash(), before, "nothing posted"); + // No admin: no access log. + let poster = home.token_with("acme", "poster", &["read", "write", "post"]); + let poster = connect_with(&home, "acme", Some(poster)).await.unwrap(); + let err = call(&poster, "books.access_log", json!({})) + .await + .unwrap_err(); + assert!(err.contains("needs books:admin"), "{err}"); + // A token without books:read reads no resource. + let blind = home.token_with("acme", "blind", &["post"]); + let blind = connect_with(&home, "acme", Some(blind)).await.unwrap(); + assert!( + blind + .read_resource(rmcp::model::ReadResourceRequestParams::new( + "wanderbooks://handbook" + )) + .await + .is_err() + ); + assert!(offered(&blind).await.iter().all(|t| t != "books.chart")); + drop((full, reader, writer, poster, blind)); + assert!(server.close().success()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn every_call_is_recorded_once_refusals_included() { + let home = Home::new("accesslog"); + let server = Server::start(&home); + home.create("acme", "USD"); + let admin_text = home.token("acme"); + let full = connect(&home, "acme").await.unwrap(); + chart(&full).await; // 3 ok + let id = post_one(&full, 7).await; // draft, 2 lines, post: ok or an error + let _ = id; + // A call that errs (bad arguments) and one refused by scope. + assert!( + call(&full, "books.get_entry", json!({"entry_id": 999_999})) + .await + .is_err() + ); + let reader = home.token_with("acme", "reader", &["read"]); + let reader = connect_with(&home, "acme", Some(reader)).await.unwrap(); + let marker = "a-distinctive-argument-1234"; + assert!( + call( + &reader, + "books.add_account", + json!({"code": "1100", "name": marker, "account_type": "asset"}) + ) + .await + .is_err() + ); + // A request with no token at all. + assert_eq!(raw_initialize(&home, "acme", None).await.0, 401); + // Read through the tool (admin): newest first, all of them. + let log = call(&full, "books.access_log", json!({"limit": 500})) + .await + .unwrap(); + let records = log["records"].as_array().unwrap(); + assert_eq!(log["total"].as_u64().unwrap() as usize, records.len()); + assert_eq!(log["more"], false); + assert_eq!(log["head"]["seq"].as_u64().unwrap() as usize, records.len()); + let seqs: Vec = records.iter().map(|r| r["seq"].as_u64().unwrap()).collect(); + let mut sorted = seqs.clone(); + sorted.sort_unstable_by(|a, b| b.cmp(a)); + assert_eq!(seqs, sorted, "newest first"); + let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect(); + // Every tool call made so far, one record each: 3 accounts, the + // posting (draft, two lines, post: up to four), the failed lookup, + // the refusal. + let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count(); + assert_eq!(by("books.add_account"), 4); + assert_eq!(by("books.get_entry"), 1); + assert_eq!(by("books.draft_entry"), 1); + assert_eq!(by("books.add_line"), 2); + assert_eq!(by("books.post_entry"), 1); + assert_eq!(by("books.access_log"), 0, "recorded once answered"); + let refused: Vec<&&Value> = tools + .iter() + .filter(|r| r["result"]["refused"] == "insufficient_scope") + .collect(); + assert_eq!(refused.len(), 1); + assert_eq!(refused[0]["principal"]["label"], "reader"); + assert!( + refused[0]["args_digest"] + .as_str() + .is_some_and(|d| d.len() == 64) + ); + let errored = tools.iter().filter(|r| r["result"] == "error").count(); + assert!(errored >= 1); + // Writes carry the head after them; reads don't. + let added = tools + .iter() + .find(|r| r["subject"] == "books.add_account" && r["result"] == "ok") + .unwrap(); + assert!(added["head_after"]["seq"].as_u64().unwrap() >= 1); + assert!(refused[0].get("head_after").is_none()); + // Each write's head is its own: oldest first, the heads climb, and + // the last is the journal's head now; a call that wrote nothing has + // none (review of slice 4, round 2). + let mut heads: Vec<(u64, String)> = Vec::new(); + for r in records.iter().rev() { + let wrote = r.get("head_after").is_some(); + let reads = ["books.get_entry", "books.access_log", "books.chart"] + .contains(&r["subject"].as_str().unwrap_or_default()); + if reads || r["action"] != "tool" || r["result"] != "ok" { + assert!(!wrote, "{r}"); + } + if wrote { + heads.push(( + r["head_after"]["seq"].as_u64().unwrap(), + r["head_after"]["hash"].as_str().unwrap().to_string(), + )); + } + } + assert!(heads.windows(2).all(|w| w[0].0 < w[1].0), "{heads:?}"); + let snap = ledger_store::Snapshot::load(&home.book_dir("acme")).unwrap(); + assert_eq!(heads.last().unwrap().1, snap.head_hash()); + assert_eq!(added["client"]["name"], "rmcp"); + let door: Vec<&Value> = records + .iter() + .filter(|r| r["action"] == "connect") + .collect(); + assert_eq!(door.len(), 1); + assert_eq!(door[0]["result"]["refused"], "unauthorized"); + assert_eq!(door[0]["principal"]["kind"], "unknown"); + // The access_log call itself is the next record. + let next = call(&full, "books.access_log", json!({"limit": 1})) + .await + .unwrap(); + assert_eq!(next["records"][0]["subject"], "books.access_log"); + assert_eq!(next["more"], true); + // Filters and paging. + let mine = call(&full, "books.access_log", json!({"token": "reader"})) + .await + .unwrap(); + assert!( + mine["records"] + .as_array() + .unwrap() + .iter() + .all(|r| r["principal"]["label"] == "reader") + ); + let only_refused = call(&full, "books.access_log", json!({"outcome": "refused"})) + .await + .unwrap(); + assert_eq!(only_refused["records"].as_array().unwrap().len(), 2); + let top = seqs[0]; + let older = call( + &full, + "books.access_log", + json!({"before": top, "limit": 2}), + ) + .await + .unwrap(); + assert!( + older["records"] + .as_array() + .unwrap() + .iter() + .all(|r| r["seq"].as_u64().unwrap() < top) + ); + for bad in [ + json!({"limit": 0}), + json!({"limit": 501}), + json!({"outcome": "maybe"}), + ] { + assert!(call(&full, "books.access_log", bad).await.is_err()); + } + // On disk: a chain that verifies, holding no token and no argument. + let (on_disk, head) = ledger_store::access::read(&home.book_dir("acme")).unwrap(); + assert_eq!(head.seq as usize, on_disk.len()); + let text = std::fs::read_to_string(home.book_dir("acme").join("access.jsonl")).unwrap(); + assert!( + !text.contains(&admin_text[3..]), + "a token's text was logged" + ); + assert!(!text.contains(marker), "an argument was logged"); + assert_eq!(mode(&home.book_dir("acme").join("access.jsonl")), 0o600); + drop((full, reader)); + assert!(server.close().success()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_session_is_used_only_with_the_token_that_opened_it() { + let home = Home::new("sessions"); + let server = Server::start(&home); + home.create("acme", "USD"); + let first = home.token("acme"); + let second = home.token_with("acme", "other", &["read", "write", "post", "admin"]); + let (status, _, session, _) = raw_initialize(&home, "acme", Some(&first)).await; + assert_eq!(status, 200); + let session = session.unwrap(); + let list = |bearer: String| { + let session = session.clone(); + let http = mcp_http(&home); + async move { + http.post("http://localhost/b/acme/mcp") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .bearer_auth(bearer) + .body(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string()) + .send() + .await + .unwrap() + } + }; + let r = list(second).await; + assert_eq!(r.status().as_u16(), 403); + assert_eq!(r.headers()["x-wanderbooks-refused"], "session_token"); + let r = list(first).await; + assert_eq!(r.status().as_u16(), 200, "{:?}", r.text().await); + assert!(server.close().success()); +} + +#[test] +fn a_damaged_access_log_keeps_its_books_closed() { + let home = Home::new("damagedlog"); + let server = Server::start(&home); + home.create("acme", "USD"); + home.create("beta", "USD"); + assert!(server.close().success()); + std::fs::write(home.book_dir("acme").join("access.jsonl"), "not a record\n").unwrap(); + let server = Server::start(&home); + match home.admin(Request::List) { + Response::Books(books) => { + let acme = books.iter().find(|b| b.id == "acme").unwrap(); + assert!(!acme.open); + let why = acme.problem.as_deref().unwrap(); + assert!(why.contains("access log"), "{why}"); + home.assert_hidden(why); + assert!(books.iter().find(|b| b.id == "beta").unwrap().open); + } + other => panic!("{other:?}"), + } + assert!(server.close().success()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn damaged_token_records_refuse_every_agent_but_not_the_list() { + let home = Home::new("damagedauth"); + let server = Server::start(&home); + home.create("acme", "USD"); + let token = home.token("acme"); + assert!(server.close().success()); + let path = home.root().join("auth/principals.jsonl"); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replace("books:admin", "books:read")).unwrap(); + let server = Server::start(&home); + assert!(matches!(home.admin(Request::List), Response::Books(_))); + let (status, _, _, body) = raw_initialize(&home, "acme", Some(&token)).await; + assert_eq!(status, 401); + assert!(body.contains("record of agent tokens"), "{body}"); + home.assert_hidden(&body); + match home.admin(Request::TokenList) { + Response::Refused(why) => assert!(why.contains("record of agent tokens"), "{why}"), + other => panic!("{other:?}"), + } + assert!(server.close().success()); +} + +/// Review of slice 4, finding 1: the access log gives no TIN away, not +/// in its text and not through its digest of the arguments (one with the +/// TIN masked, so no guess at it can be checked). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_access_log_gives_no_tin_away() { + let home = Home::new("tincanary"); + let server = Server::start(&home); + home.create("acme", "USD"); + let full = connect(&home, "acme").await.unwrap(); + call( + &full, + "books.add_party", + json!({"name": "Pat Example", "roles": ["contractor"]}), + ) + .await + .unwrap(); + let tin = "123-45-6789"; + let args = json!({"party": "Pat Example", "tin": tin, "tin_type": "ssn"}); + // Sealed or not (the vault may refuse in a fresh root), the call is + // recorded; and once more by a token that may not, refused. + let _ = call(&full, "books.set_tin", args.clone()).await; + let reader = home.token_with("acme", "reader", &["read"]); + let reader = connect_with(&home, "acme", Some(reader)).await.unwrap(); + assert!(call(&reader, "books.set_tin", args.clone()).await.is_err()); + let text = std::fs::read_to_string(home.book_dir("acme").join("access.jsonl")).unwrap(); + assert!(!text.contains(tin) && !text.contains("123456789"), "{text}"); + let other = json!({"party": "Pat Example", "tin": "987-81-4321", "tin_type": "ssn"}); + let want = ledger_store::access::digest(other.as_object().map(|_| &other).unwrap()); + let (records, _) = ledger_store::access::read(&home.book_dir("acme")).unwrap(); + let set: Vec<_> = records + .iter() + .filter(|r| r.access.subject == "books.set_tin") + .collect(); + assert_eq!(set.len(), 2, "{records:?}"); + for r in set { + assert_eq!( + r.access.args_digest.as_deref(), + Some(want.as_str()), + "{r:?}" + ); + } + drop((full, reader)); + assert!(server.close().success()); +} + +/// Review of slice 4, finding 6: a session id the server never opened is +/// bound to nothing (no flood of made-up ids binds or unbinds anything), +/// and an ended session is forgotten. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn only_sessions_the_server_opened_are_bound() { + let home = Home::new("sessbind"); + let server = Server::start(&home); + home.create("acme", "USD"); + let first = home.token("acme"); + let second = home.token_with("acme", "other", &["read", "write", "post", "admin"]); + let (_, _, session, _) = raw_initialize(&home, "acme", Some(&first)).await; + let session = session.unwrap(); + let request = |bearer: String, sid: String, method: reqwest::Method| { + let http = mcp_http(&home); + async move { + http.request(method, "http://localhost/b/acme/mcp") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", sid) + .header("mcp-protocol-version", "2025-06-18") + .bearer_auth(bearer) + .body(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}).to_string()) + .send() + .await + .unwrap() + .status() + .as_u16() + } + }; + // Made-up ids: not this server's sessions, so not found, and bound to + // nothing. + for n in 0..50 { + let made_up = format!("not-a-session-{n}"); + assert_eq!( + request(second.clone(), made_up, reqwest::Method::POST).await, + 404 + ); + } + // The real one is still bound to the token that opened it. + assert_eq!( + request(second.clone(), session.clone(), reqwest::Method::POST).await, + 403 + ); + assert_eq!( + request(first.clone(), session.clone(), reqwest::Method::POST).await, + 200 + ); + // Ended: forgotten (the next use of its id is simply not found). + let ended = request(first.clone(), session.clone(), reqwest::Method::DELETE).await; + assert!((200..300).contains(&ended), "{ended}"); + assert_eq!( + request(second.clone(), session, reqwest::Method::POST).await, + 404 + ); + // "bearer" in any case. + let (status, ..) = raw_initialize(&home, "acme", None).await; + assert_eq!(status, 401); + let r = mcp_http(&home) + .post("http://localhost/b/acme/mcp") + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("authorization", format!("bEaReR {first}")) + .body( + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "raw", "version": "0"}}}) + .to_string(), + ) + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 200); + assert!(server.close().success()); +} + +/// Review of slice 4, findings 2 to 4: no token below books:admin gets a +/// full TIN out, and none below books:post changes the vault or the +/// approval controls. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_vault_and_the_approval_controls_need_their_scopes() { + let home = Home::new("vaultscope"); + let server = Server::start(&home); + home.create("acme", "USD"); + let writer = home.token_with("acme", "writer", &["read", "write", "post"]); + let w = connect_with(&home, "acme", Some(writer)).await.unwrap(); + for (tool, args) in [ + ( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ), + ("books.export_cpa_package", json!({"as_of": "2026-12-31"})), + ("books.get_w9", json!({"party": "x"})), + ] { + let err = call(&w, tool, args).await.unwrap_err(); + assert!(err.contains("needs books:admin"), "{tool}: {err}"); + } + let plain = home.token_with("acme", "plain", &["read", "write"]); + let p = connect_with(&home, "acme", Some(plain)).await.unwrap(); + for (tool, args) in [ + ( + "books.set_tin", + json!({"party": "x", "tin": "123-45-6789", "tin_type": "ssn"}), + ), + ( + "books.retire_convention", + json!({"id": "x", "decided_by": "Pat"}), + ), + ( + "books.approve_entry", + json!({"entry_id": 1, "approver": "Pat"}), + ), + ( + "books.set_convention", + json!({"id": "x", "kind": "approval_rule", "title": "x", "body_md": "x", + "decided_by": "Pat", "structured": {"approver": "Pat"}}), + ), + ] { + let err = call(&p, tool, args).await.unwrap_err(); + assert!(err.contains("needs books:post"), "{tool}: {err}"); + } + // Another kind of convention is a write. + let note = call( + &p, + "books.set_convention", + json!({"id": "a-note", "kind": "note", "title": "A note", "body_md": "Hello.", + "decided_by": "Pat"}), + ) + .await; + assert!(note.is_ok(), "{note:?}"); + drop((w, p)); + assert!(server.close().success()); +} diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index 5b4c2f2..37b4e42 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -17,8 +17,10 @@ use std::process::{Child, Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -/// Short timings for tests (the defaults are the plan's). -const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000"; +/// Short timings for tests (the defaults are the plan's), and agents' +/// tokens kept in files in the test root, not this computer's keyring. +const KNOBS: &str = + "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,file_keyring=1"; /// A home with a data root, and the program the tests run (possibly a /// copy, for restarts). @@ -91,9 +93,17 @@ impl Home { c.output().unwrap() } + /// New books, and the agents' token the bridges use for them. fn create(&self, id: &str) { let out = self.books(&["create", id]); assert!(out.status.success(), "{out:?}"); + let out = self + .cmd("token") + .args(["create", "--book", id]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); } /// The pids running `mode` for this root. @@ -569,7 +579,7 @@ fn the_bridge_answers_initialize_itself_during_a_slow_start() { "--book", "acme", "--test-knobs", - "close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000", + "close_after=1500,backoff=50,server_delay=3000,init_wait=300,queue_wait=30000,file_keyring=1", ]); let mut a = Agent::from(c); let started = Instant::now(); @@ -638,7 +648,7 @@ fn the_bridge_gives_up_on_books_that_never_open() { "--book", "acme", "--test-knobs", - "close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000", + "close_after=800,backoff=50,server_delay=20000,init_wait=200,queue_wait=1000,file_keyring=1", ]); let mut a = Agent::from(c); a.initialize(); @@ -1247,7 +1257,7 @@ fn a_call_cancelled_at_once_is_never_made() { "--book", "acme", "--test-knobs", - "close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000", + "close_after=1500,backoff=50,server_delay=1500,init_wait=200,queue_wait=30000,file_keyring=1", ]); let mut a = Agent::from(c); a.initialize(); @@ -1317,7 +1327,7 @@ fn a_signalled_bridge_goes_at_once() { "--book", "acme", "--test-knobs", - "close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000", + "close_after=1500,backoff=50,server_delay=20000,init_wait=200,queue_wait=30000,file_keyring=1", ]); let mut a = Agent::from(c); a.initialize(); @@ -1755,7 +1765,7 @@ fn books_made_mid_session_and_bad_requests() { "--book", "acme", "--test-knobs", - "close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000", + "close_after=2500,backoff=50,server_delay=2000,init_wait=200,queue_wait=30000,file_keyring=1", ]); let mut d = Agent::from(c); d.initialize(); @@ -1939,3 +1949,104 @@ fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() { assert_eq!(fd2, Path::new("/dev/null")); assert!(a.end().is_some_and(|s| s.success())); } + +/// P2 slice 4: `wanderbooks token` keeps the token where the bridge finds +/// it (commands.rs INVARIANT 4, bridge.rs INVARIANT 11): made, used, +/// limited by its scopes, listed without its text, revoked at once. +#[test] +fn the_token_commands_and_the_bridge() { + let home = Home::new("tokencmd"); + let out = home.books(&["create", "acme"]); + assert!(out.status.success(), "{out:?}"); + let token = |args: &[&str]| { + let out = home + .cmd("token") + .args(args) + .stdin(Stdio::null()) + .output() + .unwrap(); + ( + out.status.code(), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) + }; + // A session begun before any token: told how to make one. + let mut a = Agent::start(&home, "acme"); + a.initialize(); + let err = a.call("books.start", json!({})).unwrap_err(); + assert!(err.contains("wanderbooks token create"), "{err}"); + // A read-only token, made: the same session uses it from now on. + let (code, made, _) = token(&["create", "--book", "acme", "--scopes", "read"]); + assert_eq!(code, Some(0), "{made}"); + assert!(made.contains("kept in this computer's keyring"), "{made}"); + assert!( + made.contains("books:read") && !made.contains("wb_"), + "{made}" + ); + assert!(!made.contains(&home.root().display().to_string())); + assert!(a.call("books.start", json!({})).is_ok()); + let err = a + .call( + "books.add_account", + json!({"code": "1000", "name": "Cash", "account_type": "asset"}), + ) + .unwrap_err(); + assert!(err.contains("needs books:write"), "{err}"); + // A name taken; replaced on request, and the running session takes + // the new token at its next call (the old one refused at the door, the + // keyring read again: review of slice 4). + let (code, _, err) = token(&["create", "--book", "acme"]); + assert_eq!(code, Some(1)); + assert!(err.contains("already have a token"), "{err}"); + let (code, _, _) = token(&["create", "--book", "acme", "--replace"]); + assert_eq!(code, Some(0)); + a.call( + "books.add_account", + json!({"code": "1000", "name": "Cash", "account_type": "asset"}), + ) + .unwrap(); + assert!(a.end().is_some()); + let mut b = Agent::start(&home, "acme"); + b.initialize(); + for (code, name, ty) in [ + ("3000", "Capital", "equity"), + ("6000", "Supplies", "expense"), + ] { + b.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ) + .unwrap(); + } + // Listed without its text; printed only by `print`. + let (_, listed, _) = token(&["list"]); + assert!( + listed.contains("acme agent books:read,books:write,books:post,books:admin"), + "{listed}" + ); + assert!(!listed.contains("wb_")); + let (code, printed, _) = token(&["print", "--book", "acme"]); + assert_eq!(code, Some(0)); + assert!( + printed.trim().starts_with("wb_") && printed.trim().len() == 67, + "{printed}" + ); + // Revoked: the next call is refused, and the keyring forgets it. + let (code, said, _) = token(&["revoke", "--book", "acme"]); + assert_eq!(code, Some(0), "{said}"); + assert!(b.call("books.chart", json!({})).is_err()); + let (code, _, err) = token(&["print", "--book", "acme"]); + assert_eq!(code, Some(1)); + assert!(err.contains("keeps no token"), "{err}"); + let (_, listed, _) = token(&["list"]); + assert!(listed.contains("No tokens yet"), "{listed}"); + // Asked of books there aren't, or with a scope there isn't. + let (code, _, err) = token(&["create", "--book", "nobody"]); + assert_eq!(code, Some(1)); + assert!(err.contains("no books called"), "{err}"); + let (code, _, err) = token(&["create", "--book", "acme", "--scopes", "read,everything"]); + assert_eq!(code, Some(2)); + assert!(err.contains("isn't a scope"), "{err}"); + assert!(b.end().is_some()); +} diff --git a/crates/ledger-cli/tests/setup.rs b/crates/ledger-cli/tests/setup.rs index 521af07..e4d9ad2 100644 --- a/crates/ledger-cli/tests/setup.rs +++ b/crates/ledger-cli/tests/setup.rs @@ -152,3 +152,156 @@ fn another_home_ignores_this_accounts_data_home() { "XDG_DATA_HOME was used" ); } + +/// Run `args` at a terminal (a pseudo-terminal, through util-linux +/// `script`), typing `lines` one at a time: its exit code and what the +/// terminal showed. +fn at_terminal(args: &[String], lines: &[&str]) -> (i32, String) { + use std::io::Write; + let command = std::iter::once(env!("CARGO_BIN_EXE_wanderbooks").to_string()) + .chain(args.iter().cloned()) + .collect::>() + .join(" "); + let mut child = Command::new("script") + .args(["-qefc", &command, "/dev/null"]) + .env_remove("XDG_DATA_HOME") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("util-linux script"); + let mut stdin = child.stdin.take().unwrap(); + // What it shows, as it shows it: each line is typed only once it has + // asked for it (a prompt ending ": "), never before it stops echoing. + let shown_so_far = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let mut stdout = child.stdout.take().unwrap(); + let reader = { + let shown = shown_so_far.clone(); + std::thread::spawn(move || { + use std::io::Read; + let mut buf = [0u8; 4096]; + while let Ok(n) = stdout.read(&mut buf) { + if n == 0 { + break; + } + shown.lock().unwrap().extend_from_slice(&buf[..n]); + } + }) + }; + let prompts = |shown: &[u8]| { + String::from_utf8_lossy(shown) + .split(['\r', '\n']) + .filter(|l| l.ends_with(": ") || l.ends_with(':')) + .count() + }; + for (i, line) in lines.iter().enumerate() { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while prompts(&shown_so_far.lock().unwrap()) <= i { + if child.try_wait().unwrap().is_some() || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + // It turns its echo off just after asking. + std::thread::sleep(std::time::Duration::from_millis(150)); + let _ = stdin.write_all(format!("{line}\n").as_bytes()); + } + // Kept open until it ends: script stops when its input does. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while child.try_wait().unwrap().is_none() { + assert!(std::time::Instant::now() < deadline, "it never ended"); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + drop(stdin); + let out = child.wait_with_output().unwrap(); + let _ = reader.join(); + let shown = format!( + "{}{}", + String::from_utf8_lossy(&shown_so_far.lock().unwrap()), + String::from_utf8_lossy(&out.stderr) + ); + (out.status.code().unwrap_or(-1), shown) +} + +const FIRST: &str = "correct horse battery"; +const SECOND: &str = "a different staple phrase"; + +#[test] +fn setup_at_a_terminal_sets_the_owner_passphrase() { + let home = tempfile::Builder::new() + .prefix("wb-pass-") + .tempdir() + .unwrap(); + let args = vec![ + "setup".to_string(), + "--home".into(), + home.path().display().to_string(), + ]; + let (code, shown) = at_terminal(&args, &[FIRST, FIRST]); + assert_eq!(code, 0, "{shown}"); + assert!(shown.contains("The owner passphrase is set."), "{shown}"); + assert!(!shown.contains(FIRST), "the passphrase was shown: {shown}"); + assert!(!shown.contains(home.path().to_str().unwrap()), "{shown}"); + let auth = home.path().join(".local/share/wanderbooks/auth"); + assert!(ledger_store::passphrase::verify(&auth, FIRST).unwrap()); + assert_eq!(mode(&auth), 0o700); + assert_eq!(mode(&auth.join("owner.json")), 0o600); + // Run again, it asks for nothing more. + let (code, shown) = at_terminal(&args, &[]); + assert_eq!(code, 0, "{shown}"); + assert!(!shown.contains("passphrase"), "{shown}"); +} + +#[test] +fn the_passphrase_is_typed_by_a_person_and_changed_only_with_the_one_in_force() { + let home = tempfile::Builder::new() + .prefix("wb-pass-") + .tempdir() + .unwrap(); + // Set up without a terminal: the folder, and a note. + let out = wanderbooks() + .args(["setup", "--home"]) + .arg(home.path()) + .env_remove("XDG_DATA_HOME") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!(String::from_utf8_lossy(&out.stdout).contains("wanderbooks passphrase")); + let root = home.path().join(".local/share/wanderbooks"); + let auth = root.join("auth"); + assert!(!ledger_store::passphrase::is_set(&auth).unwrap()); + let args = vec![ + "passphrase".to_string(), + "--root".into(), + root.display().to_string(), + ]; + // No terminal (a script, an agent): refused, nothing set. + let out = wanderbooks() + .args(&args) + .stdin(std::process::Stdio::null()) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + assert!(String::from_utf8_lossy(&out.stderr).contains("typed by a person")); + assert!(!ledger_store::passphrase::is_set(&auth).unwrap()); + // Too short, or the two not matching: nothing set. + for lines in [["short", "short"], [FIRST, SECOND]] { + let (code, shown) = at_terminal(&args, &lines); + assert_eq!(code, 1, "{shown}"); + assert!(!ledger_store::passphrase::is_set(&auth).unwrap(), "{shown}"); + } + let (code, shown) = at_terminal(&args, &[FIRST, FIRST]); + assert_eq!(code, 0, "{shown}"); + // A change needs the one in force. + let (code, shown) = at_terminal(&args, &["not the passphrase at all"]); + assert_eq!(code, 1, "{shown}"); + assert!(shown.contains("isn't the owner passphrase"), "{shown}"); + assert!(ledger_store::passphrase::verify(&auth, FIRST).unwrap()); + let (code, shown) = at_terminal(&args, &[FIRST, SECOND, SECOND]); + assert_eq!(code, 0, "{shown}"); + assert!(!shown.contains(FIRST) && !shown.contains(SECOND), "{shown}"); + assert!(ledger_store::passphrase::verify(&auth, SECOND).unwrap()); + assert!(!ledger_store::passphrase::verify(&auth, FIRST).unwrap()); + let kept = std::fs::read_to_string(auth.join("owner.json")).unwrap(); + assert!(!kept.contains("staple"), "the text itself was kept"); +} diff --git a/crates/ledger-host/Cargo.toml b/crates/ledger-host/Cargo.toml index d5a64a5..85dd6df 100644 --- a/crates/ledger-host/Cargo.toml +++ b/crates/ledger-host/Cargo.toml @@ -16,10 +16,20 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io reqwest = "0.13" base64 = "0.22" tokio-util = { version = "0.7", features = ["rt"] } +# Agents' tokens are kept in the OS keyring (plan 3.3, 3.5): the Secret +# Service on Linux. macOS and Windows stores come with P11. +keyring-core = "1" +# The owner passphrase is typed at the terminal, unechoed. +rpassword = "7" +# Token texts and passphrases are wiped from memory when dropped. +zeroize = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +zbus-secret-service-keyring-store = { version = "1", features = ["rt-async-io-crypto-rust"] } + [dev-dependencies] tempfile = "3" diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs index c7cee8d..45e03d4 100644 --- a/crates/ledger-host/src/bridge.rs +++ b/crates/ledger-host/src/bridge.rs @@ -39,6 +39,13 @@ //! are not passed on, in initialize or in a request's own metadata. //! 10. Ended by its stdin or by a signal, the bridge ends its upstream //! session. +//! 11. Every request upstream carries its token, read from the keyring +//! (tokens.rs) before each new session and each request until it is +//! found, and again when the door refuses it (the call then sent once +//! more if another was found), so a token made or replaced +//! mid-session is used from the next call; without one, calls are +//! answered with the server's refusal, which says how the human makes +//! one. The token is never written to the agent or to stderr. //! ``` use crate::knobs::{FLAG, Knobs}; @@ -61,6 +68,9 @@ const OWN_ID: &str = "wanderbooks-bridge-"; const SERVER_ERROR: i64 = -32000; /// What the server says when it is closing and didn't run a request. const CLOSING: &str = "the books are closing"; +/// The refusal code the server's door gives a request it didn't run +/// because the books are closing. +const REFUSED_CLOSING: &str = "closing"; /// What rmcp says, as the whole body, for an unknown session. const NO_SESSION: &str = "Not Found: Session not found"; @@ -124,6 +134,12 @@ enum NotRun { struct Bridge { book: String, + /// The name of the token it uses (tokens.rs). + token_name: String, + /// The token's text, once read from the keyring (INVARIANT 11). + token: Mutex>, + /// Where it is kept: its account, and the root's `run/`. + keyring: std::sync::OnceLock<(crate::tokens::Account, PathBuf)>, http: reqwest::Client, knobs: Knobs, to_agent: mpsc::UnboundedSender, @@ -309,6 +325,69 @@ impl Bridge { format!("http://localhost/b/{}/mcp", self.book) } + /// Read the token from the keyring if it isn't known yet (INVARIANT + /// 11): a token made after the session began is found on the next + /// try. A keyring that fails is said on stderr; the server's refusal + /// then tells the agent what to do. + async fn find_token(&self) { + if self + .token + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none() + { + self.read_token().await; + } + } + + /// Read the token from the keyring, keeping it if found: whether a + /// token other than the one held was found (one made since, or one + /// that replaced it). + async fn read_token(&self) -> bool { + let Some((account, run_dir)) = self.keyring.get().cloned() else { + return false; + }; + let keeper = crate::tokens::keeper(&run_dir, &self.knobs); + let read = tokio::time::timeout( + self.knobs.queue_wait, + tokio::task::spawn_blocking(move || keeper.get(&account)), + ) + .await; + match read { + Ok(Ok(Ok(Some(token)))) => { + let mut held = self.token.lock().unwrap_or_else(|e| e.into_inner()); + let new = held.as_deref() != Some(token.as_str()); + *held = Some(token); + new + } + Ok(Ok(Ok(None))) => false, + Ok(Ok(Err(why))) => { + eprintln!("wanderbooks mcp: the token for these books couldn't be read: {why}"); + false + } + Ok(Err(_)) | Err(_) => { + eprintln!( + "wanderbooks mcp: the keyring didn't give the token for these books in time: is it locked?" + ); + false + } + } + } + + /// A request upstream, carrying the token (INVARIANT 11). + fn upstream(&self, method: reqwest::Method) -> reqwest::RequestBuilder { + let req = self.http.request(method, self.url()); + match self + .token + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_deref() + { + Some(token) => req.bearer_auth(token), + None => req, + } + } + fn own_id(&self) -> Value { let n = self .next_id @@ -322,9 +401,28 @@ impl Bridge { /// Send one message upstream on `up`'s session. async fn send(&self, message: &Value, up: &Upstream) -> Sent { + // A client of the 2026-07-28 era opens no session: its token is + // looked for here (INVARIANT 11). + self.find_token().await; + let (sent, unauthorized) = self.send_once(message, up).await; + // Refused at the door for its token (not run): one made or + // replaced since is read from the keyring (INVARIANT 11). A + // session belongs to the token that opened it, so with a session + // the call waits for a new one (opened with the new token); with + // none it is sent once more at once. + if unauthorized && self.read_token().await { + if up.session.is_some() { + return Sent::NotRun(NotRun::NoSession); + } + return self.send_once(message, up).await.0; + } + sent + } + + /// [`Bridge::send`], once: and whether the door refused its token. + async fn send_once(&self, message: &Value, up: &Upstream) -> (Sent, bool) { let mut req = self - .http - .post(self.url()) + .upstream(reqwest::Method::POST) .header("content-type", "application/json") .header("accept", "application/json, text/event-stream") .body(serde_json::to_vec(message).unwrap_or_default()); @@ -336,15 +434,30 @@ impl Bridge { } let response = match req.send().await { Ok(r) => r, - Err(e) if e.is_connect() => return Sent::NotRun(NotRun::Unreachable), - Err(_) => return Sent::MaybeRan, + Err(e) if e.is_connect() => return (Sent::NotRun(NotRun::Unreachable), false), + Err(_) => return (Sent::MaybeRan, false), }; let status = response.status(); + let unauthorized = status == reqwest::StatusCode::UNAUTHORIZED; // Refused before it could run (books that didn't open, or none // so named): the server's words, for the agent (INVARIANT 4). + let sent = self.read_answer(response).await; + (sent, unauthorized) + } + + /// What an answer from upstream says. + async fn read_answer(&self, response: reqwest::Response) -> Sent { + let status = response.status(); let refused = response .headers() - .contains_key(ledger_mcp::local::REFUSED_HEADER); + .get(ledger_mcp::local::REFUSED_HEADER) + .map(|v| v.as_bytes().to_vec()); + // Refused at the door because the books are closing: not run, so + // sent again once they're open (INVARIANT 4). + if refused.as_deref() == Some(REFUSED_CLOSING.as_bytes()) { + return Sent::NotRun(NotRun::Closing); + } + let refused = refused.is_some(); let is_stream = response .headers() .get("content-type") @@ -406,16 +519,21 @@ impl Bridge { params: &Value, epoch: u64, ) -> Result> { + self.find_token().await; let request = json!({"jsonrpc": "2.0", "id": self.own_id(), "method": "initialize", "params": params}); - let response = self - .http - .post(self.url()) - .header("content-type", "application/json") - .header("accept", "application/json, text/event-stream") - .body(request.to_string()) - .send() - .await - .map_err(|_| None)?; + let post = || { + self.upstream(reqwest::Method::POST) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .body(request.to_string()) + .send() + }; + let mut response = post().await.map_err(|_| None)?; + // Refused for its token: once more with one made or replaced + // since (INVARIANT 11). + if response.status() == reqwest::StatusCode::UNAUTHORIZED && self.read_token().await { + response = post().await.map_err(|_| None)?; + } let session = response .headers() .get("mcp-session-id") @@ -719,6 +837,12 @@ impl Bridge { /// Keep the lease, and a session whenever the server is ready /// (INVARIANTS 1, 2, 5, 6). async fn keep(self: Arc, mut opener: Opener) { + // INVARIANT 11: where its token is kept; it is read before each + // new session until one is found. + let run_dir = opener.run_dir().to_path_buf(); + let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); + let account = crate::tokens::Account::new(&root, &self.book, &self.token_name); + let _ = self.keyring.set((account, run_dir)); let mut epoch = 0u64; loop { let mut lease = match opener.hold().await { @@ -857,8 +981,7 @@ impl Bridge { { let _ = tokio::time::timeout( Duration::from_secs(2), - self.http - .delete(self.url()) + self.upstream(reqwest::Method::DELETE) .header("mcp-session-id", session) .send(), ) @@ -1076,9 +1199,11 @@ async fn skip_line(input: &mut R) -> bool { fn usage(prog: &str) { // (`--root DIR` too, for tests: the bridge never needs a folder.) - eprintln!("usage: {prog} --book ID"); + eprintln!("usage: {prog} --book ID [--token NAME]"); eprintln!(" Connects an agent (over stdio) to the books called ID, opening them if"); - eprintln!(" they are closed and holding them open for the session."); + eprintln!(" they are closed and holding them open for the session. It uses the"); + eprintln!(" token NAME (default: agent) that `wanderbooks token create` kept in"); + eprintln!(" this computer's keyring."); } /// `wanderbooks mcp --book ID`. @@ -1087,12 +1212,14 @@ pub fn run( args: impl IntoIterator, ) -> Result<(), Box> { let mut book: Option = None; + let mut token_name = crate::tokens::DEFAULT_NAME.to_string(); let mut root: Option = None; let mut knobs = Knobs::default(); let mut args = args.into_iter(); while let Some(arg) = args.next() { match arg.as_str() { "--book" => book = Some(args.next().ok_or("--book needs an id")?), + "--token" => token_name = args.next().ok_or("--token needs a name")?, "--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)), FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?, "--help" | "-h" => { @@ -1143,6 +1270,9 @@ pub fn run( let (link, _) = watch::channel(Link::Opening); let bridge = Arc::new(Bridge { book, + token_name, + token: Mutex::new(None), + keyring: std::sync::OnceLock::new(), http, knobs, to_agent, @@ -1485,6 +1615,10 @@ mod against_a_server { let (link, _) = watch::channel(Link::Opening); let bridge = Arc::new(Bridge { book: "acme".into(), + // A token the fake server doesn't check; no keyring is read. + token_name: crate::tokens::DEFAULT_NAME.into(), + token: Mutex::new(Some("wb_test".into())), + keyring: std::sync::OnceLock::new(), http, knobs, to_agent, diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index b90d475..ac33e04 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1,5 +1,5 @@ -//! The owner's commands: `wanderbooks serve`, `close` and `books` (plan -//! 3.9, 5.5). +//! The owner's commands: `wanderbooks serve`, `close`, `books` and +//! `token` (plan 3.3, 3.9, 5.5). //! //! ```text //! GOAL: from a terminal the owner holds the books open, closes them, and @@ -17,6 +17,10 @@ //! 3. Adoption is previewed first and goes ahead only on a yes at the //! terminal, or --yes; a vault beside the books must be named (or //! --no-vault given): it is never silently left behind. +//! 4. A token made is kept in the keyring before anything is said of it, +//! and its text is printed only by `token print`; one that can't be +//! kept is revoked again. A token revoked is forgotten by the keyring +//! too. //! ``` use crate::knobs::{FLAG, Knobs}; @@ -104,6 +108,12 @@ async fn open(opener: &mut Opener) -> Result { /// Ask the server's admin socket, holding the books open meanwhile. fn with_server(common: &Common, request: Request) -> Result { + with_server_in(common, request).map(|(answer, _)| answer) +} + +/// [`with_server`], with the root's `run/` folder too (where the +/// keyring's test stand-in lives). +fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBuf), String> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -122,16 +132,17 @@ fn with_server(common: &Common, request: Request) -> Result { } HoldError::Failed(why) => why, })?; - let socket = opener.run_dir().join(ledger_mcp::local::ADMIN_SOCKET); + let run_dir = opener.run_dir().to_path_buf(); + let socket = run_dir.join(ledger_mcp::local::ADMIN_SOCKET); // A list is quick; a create or an adoption takes what it takes. - let wait = matches!(request, Request::List).then_some(LIST_WAIT); + let wait = matches!(request, Request::List | Request::TokenList).then_some(LIST_WAIT); let answer = tokio::task::spawn_blocking(move || admin::call_within(&socket, request, wait)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string()); drop(lease); - answer + answer.map(|a| (a, run_dir)) }); // Past its deadline, the command ends: nothing pending holds it. rt.shutdown_timeout(Duration::from_secs(1)); @@ -773,6 +784,17 @@ pub fn doctor( }; println!("The books' private folder: there, and only your account can open it."); println!("Isolation: same_user (advisory): hidden from agents, not locked against them."); + match root + .auth_dir() + .map_err(|e| e.to_string()) + .and_then(|a| ledger_store::passphrase::is_set(&a).map_err(|e| e.to_string())) + { + Ok(true) => println!("The owner passphrase: set."), + Ok(false) => println!( + "The owner passphrase: not set yet: set it in a terminal with `wanderbooks passphrase`." + ), + Err(why) => println!("The owner passphrase: {why}"), + } let run = root.run_dir().map_err(|e| e.to_string())?; match crate::opener::ask_once_within(&run, Ask::Leases, DOCTOR_WAIT) { Ok(None) => println!("{}", no_host(crate::opener::server_lock_held(&run))), @@ -793,6 +815,20 @@ pub fn doctor( Some(why) => println!(" {} didn't open: {why}", b.id), } } + // Agents' tokens: how many each book has. + match admin::call_within(&socket, Request::TokenList, Some(DOCTOR_WAIT)) { + Ok(admin::Response::Tokens(tokens)) if tokens.is_empty() => println!( + "Agents' tokens: none yet (`wanderbooks token create --book ID`)." + ), + Ok(admin::Response::Tokens(tokens)) => { + println!("Agents' tokens:"); + for t in tokens { + println!(" {} {} {}", t.book, t.name, t.scopes.join(",")); + } + } + Ok(admin::Response::Refused(why)) => println!("Agents' tokens: {why}"), + Ok(_) | Err(_) => {} + } } } else { println!("The books are opening (the books server isn't ready yet)."); @@ -828,6 +864,300 @@ pub fn doctor( Ok(()) } +fn token_usage(prog: &str) { + eprintln!("usage: {prog} create --book ID [--name NAME] [--scopes LIST] [--replace]"); + eprintln!(" {prog} list"); + eprintln!(" {prog} revoke --book ID [--name NAME]"); + eprintln!(" {prog} print --book ID [--name NAME]"); + eprintln!(" create make an agent's token for the books ID and keep it in this"); + eprintln!(" computer's keyring, where `wanderbooks mcp --book ID` finds it"); + eprintln!(" (NAME: agent unless given; LIST: read,write,post,admin unless"); + eprintln!(" given; --replace ends a token of the same name)"); + eprintln!(" list the tokens there are (never their text)"); + eprintln!(" revoke end a token, at once, and forget it here"); + eprintln!(" print the token's text, for a client that sends it itself"); +} + +/// `wanderbooks token create|list|revoke|print`. +pub fn token( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + use ledger_store::auth::Scope; + let mut common = Common::default(); + let mut words: Vec = Vec::new(); + let mut book: Option = None; + let mut name = crate::tokens::DEFAULT_NAME.to_string(); + let mut scopes: Vec = Scope::ALL.iter().map(|s| s.as_str().to_string()).collect(); + let mut replace = false; + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}")); + match arg.as_str() { + "--book" => book = Some(value("an id")?), + "--name" => name = value("a name")?, + "--scopes" => { + let list = value("a list such as read,write")?; + let mut out = Vec::new(); + for part in list.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match Scope::parse(part) { + Some(s) => out.push(s.as_str().to_string()), + None => { + eprintln!( + "error: {part:?} isn't a scope: use read, write, post or admin" + ); + return Err(exit(2)); + } + } + } + scopes = out; + } + "--replace" => replace = true, + "--help" | "-h" => { + token_usage(prog); + return Ok(()); + } + s if s.starts_with('-') => { + eprintln!("unknown flag: {s}"); + token_usage(prog); + return Err(exit(2)); + } + _ => words.push(arg), + } + } + let fail = |why: String| -> Box { + eprintln!("error: {why}"); + exit(1) + }; + let need_book = || -> Result> { + let Some(book) = book.clone() else { + eprintln!("error: which books? give --book ID"); + return Err(exit(2)); + }; + ledger_store::root::BookId::parse(&book) + .map(|id| id.to_string()) + .map_err(|e| fail(e.to_string())) + }; + let words: Vec<&str> = words.iter().map(String::as_str).collect(); + match words.as_slice() { + ["create"] => { + let book = need_book()?; + let request = Request::TokenCreate { + book: book.clone(), + name: name.clone(), + scopes, + replace, + }; + let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?; + let (info, text) = match answer { + Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)), + Response::Refused(why) => return Err(fail(why)), + other => return Err(fail(format!("the books server answered oddly: {other:?}"))), + }; + // INVARIANT 4: kept first, or taken back. + let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); + let account = crate::tokens::Account::new(&root, &book, &name); + let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text); + drop(text); + if let Err(why) = kept { + let undone = with_server( + &common, + Request::TokenRevoke { + book: book.clone(), + name: name.clone(), + // This one only: another may have replaced it. + id: Some(info.id.clone()), + }, + ); + let after = match undone { + Ok(Response::TokenRevoked(_)) => "so it was revoked again", + _ => "and revoking it again failed too: `wanderbooks token revoke` ends it", + }; + let replaced = if replace { + " The token it replaced had already ended." + } else { + "" + }; + return Err(fail(format!( + "the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}" + ))); + } + println!( + "Made the token {:?} for the books {} ({}), kept in this computer's keyring.", + info.name, + info.book, + info.scopes.join(", ") + ); + println!( + "An agent reaches the books with `wanderbooks mcp --book {}{}`.", + info.book, + if info.name == crate::tokens::DEFAULT_NAME { + String::new() + } else { + format!(" --token {}", info.name) + } + ); + Ok(()) + } + ["list"] => match with_server(&common, Request::TokenList).map_err(fail)? { + Response::Tokens(tokens) if tokens.is_empty() => { + println!("No tokens yet: `wanderbooks token create --book ID` makes one."); + Ok(()) + } + Response::Tokens(tokens) => { + for t in tokens { + println!( + "{} {} {} made {}", + t.book, + t.name, + t.scopes.join(","), + t.created + ); + } + Ok(()) + } + Response::Refused(why) => Err(fail(why)), + other => Err(fail(format!("the books server answered oddly: {other:?}"))), + }, + ["revoke"] => { + let book = need_book()?; + let request = Request::TokenRevoke { + book: book.clone(), + name: name.clone(), + id: None, + }; + let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?; + match answer { + Response::TokenRevoked(t) => { + let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); + let account = crate::tokens::Account::new(&root, &book, &name); + let forgot = crate::tokens::keeper(&run_dir, &common.knobs).delete(&account); + println!("Revoked the token {:?} for the books {}.", t.name, t.book); + if let Err(why) = forgot { + eprintln!( + "warning: this computer's keyring still holds its text ({why}); it opens nothing now" + ); + } + Ok(()) + } + Response::Refused(why) => Err(fail(why)), + other => Err(fail(format!("the books server answered oddly: {other:?}"))), + } + } + ["print"] => { + let book = need_book()?; + let opener = Opener::new( + common.root.as_deref(), + common.knobs, + Starter::Direct, + Kind::Cli, + None, + ) + .map_err(|e| fail(e.to_string()))?; + let run_dir = opener.run_dir().to_path_buf(); + let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf(); + let account = crate::tokens::Account::new(&root, &book, &name); + match crate::tokens::keeper(&run_dir, &common.knobs).get(&account) { + Ok(Some(text)) => { + println!("{text}"); + Ok(()) + } + Ok(None) => Err(fail(format!( + "this computer keeps no token {name:?} for the books {book}: `wanderbooks token create --book {book}` makes one" + ))), + Err(why) => Err(fail(why)), + } + } + _ => { + token_usage(prog); + Err(exit(2)) + } + } +} + +/// Whether a person can type a passphrase here: standard input is a +/// terminal, and the terminal itself can be opened (it is read from). +fn can_type_a_passphrase() -> bool { + at_a_terminal() && std::fs::File::open("/dev/tty").is_ok() +} + +/// Ask for the owner passphrase at the terminal and set it in `auth_dir` +/// (ledger_store::passphrase): the one in force first, if there is one, +/// then the new one twice. Nothing typed is echoed or repeated. +pub fn set_passphrase_at_terminal(auth_dir: &Path) -> Result<(), String> { + use ledger_store::passphrase; + if !can_type_a_passphrase() { + return Err( + "the owner passphrase is typed by a person, at a terminal: run `wanderbooks passphrase` in one" + .into(), + ); + } + // What is typed is wiped from memory when done with. + let ask = |prompt: &str| { + rpassword::prompt_password(prompt) + .map(zeroize::Zeroizing::new) + .map_err(|e| format!("nothing was read ({})", e.kind())) + }; + let current = if passphrase::is_set(auth_dir).map_err(|e| e.to_string())? { + let c = ask("The owner passphrase in force: ")?; + if !passphrase::verify(auth_dir, &c).map_err(|e| e.to_string())? { + return Err(passphrase::PassError::Wrong.to_string()); + } + Some(c) + } else { + None + }; + let new = ask(&format!( + "A new owner passphrase ({} characters or more): ", + passphrase::MIN_CHARS + ))?; + passphrase::check_length(&new).map_err(|e| e.to_string())?; + if *ask("The same again: ")? != *new { + return Err("the two didn't match: nothing was changed".into()); + } + passphrase::set(auth_dir, &new, current.as_ref().map(|c| c.as_str())).map_err(|e| e.to_string()) +} + +/// `wanderbooks passphrase`: set or change the owner passphrase. +pub fn passphrase( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut common = Common::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if common.take(&arg, &mut args)? { + continue; + } + match arg.as_str() { + "--help" | "-h" => { + eprintln!("usage: {prog}"); + eprintln!(" Set the owner passphrase, or change it (the one in force is asked"); + eprintln!(" first). It is what the books ask a person for before anything only"); + eprintln!(" the owner may do. Typed at a terminal, never shown."); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + return Err(exit(2)); + } + } + } + let fail = |why: String| -> Box { + eprintln!("error: {why}"); + exit(1) + }; + let root = + ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| fail(e.to_string()))?; + let auth = root.auth_dir().map_err(|e| fail(e.to_string()))?; + set_passphrase_at_terminal(&auth).map_err(fail)?; + println!("The owner passphrase is set."); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-host/src/knobs.rs b/crates/ledger-host/src/knobs.rs index 74c5dc8..9bf09ce 100644 --- a/crates/ledger-host/src/knobs.rs +++ b/crates/ledger-host/src/knobs.rs @@ -27,6 +27,9 @@ pub struct Knobs { pub queue_wait: Duration, /// How long a command waits for the books to open. pub open_wait: Duration, + /// Keep agents' tokens in files in the root's `run/`, not the OS + /// keyring (tests: tokens.rs INVARIANT 3). `file_keyring=1`. + pub file_keyring: bool, } impl Default for Knobs { @@ -39,6 +42,7 @@ impl Default for Knobs { init_wait: Duration::from_secs(5), queue_wait: Duration::from_secs(60), open_wait: Duration::from_secs(120), + file_keyring: false, } } } @@ -54,6 +58,14 @@ impl Knobs { let (name, value) = pair .split_once('=') .ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?; + if name == "file_keyring" { + k.file_keyring = match value { + "1" => true, + "0" => false, + _ => return Err(format!("{FLAG}: file_keyring is 1 or 0")), + }; + continue; + } let ms: u64 = value .parse() .map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?; @@ -90,6 +102,9 @@ impl Knobs { parts.push(format!("{name}={}", now.as_millis())); } } + if self.file_keyring { + parts.push("file_keyring=1".into()); + } (!parts.is_empty()).then(|| parts.join(",")) } @@ -137,13 +152,23 @@ mod tests { let args = k.args(); assert_eq!(args[0], FLAG); assert_eq!(Knobs::parse(&args[1]).unwrap(), k); + let keyring = Knobs::parse("file_keyring=1,backoff=5").unwrap(); + assert!(keyring.file_keyring); + assert_eq!(Knobs::parse(&keyring.args()[1]).unwrap(), keyring); + assert!(!Knobs::parse("file_keyring=0").unwrap().file_keyring); let one = Knobs::parse("close_after=1").unwrap(); assert_eq!(one.to_arg().as_deref(), Some("close_after=1")); } #[test] fn a_bad_knob_is_refused() { - for bad in ["close_after", "close_after=x", "nope=1", "close_after=-1"] { + for bad in [ + "close_after", + "close_after=x", + "nope=1", + "close_after=-1", + "file_keyring=yes", + ] { assert!(Knobs::parse(bad).is_err(), "{bad}"); } } diff --git a/crates/ledger-host/src/lib.rs b/crates/ledger-host/src/lib.rs index 2a0007e..0534d6f 100644 --- a/crates/ledger-host/src/lib.rs +++ b/crates/ledger-host/src/lib.rs @@ -7,7 +7,8 @@ //! ends. Each part is a mode of the one `wanderbooks` executable: //! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks //! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in -//! [`commands`] (`serve`, `close`, `books`). +//! [`commands`] (`serve`, `close`, `books`, `token`). Agents' tokens are +//! kept in the OS keyring ([`tokens`]). #[cfg(unix)] pub mod bridge; @@ -23,6 +24,7 @@ pub mod lease; pub mod opener; #[cfg(unix)] mod start; +pub mod tokens; /// How a mode stops when it can't go on: the reason is already printed, /// and the number is the process's exit code. diff --git a/crates/ledger-host/src/tokens.rs b/crates/ledger-host/src/tokens.rs new file mode 100644 index 0000000..f6a60f3 --- /dev/null +++ b/crates/ledger-host/src/tokens.rs @@ -0,0 +1,266 @@ +//! Where an agent's token is kept on this computer: the OS keyring (plan +//! 3.3, 3.5, D-23), read by the bridge, so no agent's configuration +//! holds it. +//! +//! ```text +//! GOAL: `wanderbooks token create` keeps the token it was shown where +//! the bridge finds it, and nowhere an agent's configuration or the +//! screen would show it. +//! SOURCE: plan 3.3 ("kept in the OS keyring, minted by wanderbooks +//! connect, revocable"), 2.1 ("Local token: OS keyring, read by +//! the bridge"), 3.5 ("plaintext in the user's keyring, which any +//! process running as that user can read (advisory locally)"). +//! INVARIANTS: +//! 1. A token is kept under the service "wanderbooks" and an account +//! naming the root (a hash of its path, never the path), the book +//! and the token's name, so two roots or two books never share one. +//! 2. Nothing here prints or logs a token's text, and no error repeats +//! it. +//! 3. The keyring stands in for nothing else: with no keyring the +//! command says so; only the test knob `file_keyring` keeps tokens +//! in files instead (in the root's `run/`, 0600). +//! ``` + +use crate::knobs::Knobs; +use std::path::{Path, PathBuf}; + +/// The keyring service tokens are kept under (INVARIANT 1). +pub const SERVICE: &str = "wanderbooks"; +/// A token's name when none is given. +pub const DEFAULT_NAME: &str = "agent"; + +/// A place tokens are kept. +pub trait Keeper: Send + Sync { + /// The token kept for `account`, if any. + fn get(&self, account: &Account) -> Result, String>; + /// Keep `text` for `account`, replacing any. + fn set(&self, account: &Account, text: &str) -> Result<(), String>; + /// Forget `account`'s token (no matter if there is none). + fn delete(&self, account: &Account) -> Result<(), String>; +} + +/// Whose token: a root's book's token of a name (INVARIANT 1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Account { + root_tag: String, + book: String, + name: String, +} + +impl Account { + /// The token `name` of `book` in the root at `root`. + pub fn new(root: &Path, book: &str, name: &str) -> Self { + let real = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let hash = ledger_store::journal::sha256_hex(real.as_os_str().as_encoded_bytes()); + Self { + root_tag: hash[..16].to_string(), + book: book.to_string(), + name: name.to_string(), + } + } + + /// The keyring's account name: `//`. + pub fn key(&self) -> String { + format!("{}/{}/{}", self.root_tag, self.book, self.name) + } + + /// What the keyring's own window calls it. + fn label(&self) -> String { + format!("wanderbooks token: the books {} ({})", self.book, self.name) + } +} + +/// The keeper in force: the OS keyring, or files for tests (INVARIANT 3). +pub fn keeper(run_dir: &Path, knobs: &Knobs) -> Box { + if knobs.file_keyring { + Box::new(Files(run_dir.join("test-keyring"))) + } else { + Box::new(Os) + } +} + +/// The OS keyring. +struct Os; + +#[cfg(target_os = "linux")] +impl Os { + fn entry(&self, account: &Account) -> Result { + use keyring_core::api::CredentialStoreApi; + let store = zbus_secret_service_keyring_store::Store::new().map_err(|e| { + format!("this computer's keyring can't be reached ({e}): is a Secret Service (GNOME Keyring, KWallet) running and unlocked?") + })?; + let label = account.label(); + let modifiers = std::collections::HashMap::from([("label", label.as_str())]); + store + .build(SERVICE, &account.key(), Some(&modifiers)) + .map_err(|e| format!("the keyring refused: {e}")) + } +} + +#[cfg(target_os = "linux")] +impl Keeper for Os { + fn get(&self, account: &Account) -> Result, String> { + match self.entry(account)?.get_password() { + Ok(text) => Ok(Some(text)), + Err(keyring_core::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("the keyring refused: {e}")), + } + } + + fn set(&self, account: &Account, text: &str) -> Result<(), String> { + self.entry(account)? + .set_password(text) + .map_err(|e| format!("the keyring refused: {e}")) + } + + fn delete(&self, account: &Account) -> Result<(), String> { + match self.entry(account)?.delete_credential() { + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("the keyring refused: {e}")), + } + } +} + +#[cfg(not(target_os = "linux"))] +impl Keeper for Os { + fn get(&self, _: &Account) -> Result, String> { + Err(NOT_YET.into()) + } + fn set(&self, _: &Account, _: &str) -> Result<(), String> { + Err(NOT_YET.into()) + } + fn delete(&self, _: &Account) -> Result<(), String> { + Err(NOT_YET.into()) + } +} + +#[cfg(not(target_os = "linux"))] +const NOT_YET: &str = + "agents' tokens are kept in the keyring on Linux only, until the macOS and Windows apps (P11)"; + +/// Files, one a token, for tests (INVARIANT 3). +struct Files(PathBuf); + +impl Files { + fn path(&self, account: &Account) -> PathBuf { + self.0 + .join(ledger_store::journal::sha256_hex(account.key().as_bytes())) + } +} + +impl Keeper for Files { + fn get(&self, account: &Account) -> Result, String> { + match std::fs::read_to_string(self.path(account)) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("the test keyring refused: {}", e.kind())), + } + } + + fn set(&self, account: &Account, text: &str) -> Result<(), String> { + use std::io::Write; + let fail = |e: std::io::Error| format!("the test keyring refused: {}", e.kind()); + std::fs::create_dir_all(&self.0).map_err(fail)?; + let path = self.path(account); + let _ = std::fs::remove_file(&path); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(&path) + .and_then(|mut f| f.write_all(text.as_bytes())) + .map_err(fail) + } + + fn delete(&self, account: &Account) -> Result<(), String> { + match std::fs::remove_file(self.path(account)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("the test keyring refused: {}", e.kind())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_account_names_the_root_by_hash_and_the_book_and_token() { + let dir = tempfile::tempdir().unwrap(); + let a = Account::new(dir.path(), "acme", "agent"); + let key = a.key(); + assert!(key.ends_with("/acme/agent"), "{key}"); + assert!(!key.contains(&dir.path().display().to_string())); + assert_eq!(key.split('/').next().unwrap().len(), 16); + // The same root by another spelling is the same account. + let again = Account::new(&dir.path().join("."), "acme", "agent"); + assert_eq!(again, a); + let other = tempfile::tempdir().unwrap(); + assert_ne!(Account::new(other.path(), "acme", "agent").key(), key); + assert_ne!(Account::new(dir.path(), "beta", "agent").key(), key); + assert_ne!(Account::new(dir.path(), "acme", "codex").key(), key); + assert!(!a.label().contains('/')); + } + + #[test] + fn the_test_keyring_keeps_and_forgets() { + let dir = tempfile::tempdir().unwrap(); + let knobs = Knobs { + file_keyring: true, + ..Knobs::default() + }; + let k = keeper(dir.path(), &knobs); + let a = Account::new(dir.path(), "acme", "agent"); + assert_eq!(k.get(&a).unwrap(), None); + k.set(&a, "wb_one").unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_one")); + k.set(&a, "wb_two").unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_two")); + let b = Account::new(dir.path(), "acme", "codex"); + assert_eq!(k.get(&b).unwrap(), None); + k.delete(&a).unwrap(); + k.delete(&a).unwrap(); + assert_eq!(k.get(&a).unwrap(), None); + #[cfg(unix)] + { + k.set(&a, "wb_three").unwrap(); + use std::os::unix::fs::PermissionsExt; + let files: Vec<_> = std::fs::read_dir(dir.path().join("test-keyring")) + .unwrap() + .collect(); + assert_eq!(files.len(), 1); + let mode = files[0] + .as_ref() + .unwrap() + .metadata() + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + /// The real keyring, on a desktop with one (`cargo test -p + /// ledger-host -- --ignored os_keyring`): kept, read, replaced and + /// forgotten, under a service no one else uses. + #[cfg(target_os = "linux")] + #[test] + #[ignore] + fn os_keyring_keeps_and_forgets() { + let dir = tempfile::tempdir().unwrap(); + let a = Account::new(dir.path(), "ignored-test", "agent"); + let k = Os; + k.set(&a, "wb_test_one").unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_test_one")); + k.set(&a, "wb_test_two").unwrap(); + assert_eq!(k.get(&a).unwrap().as_deref(), Some("wb_test_two")); + k.delete(&a).unwrap(); + assert_eq!(k.get(&a).unwrap(), None); + k.delete(&a).unwrap(); + } +} diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index f0ed7f6..1a4d081 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -33,6 +33,8 @@ chrono = "0.4" thiserror = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] } tokio-util = { version = "0.7", features = ["rt"] } +# A call that panics is recorded before the panic goes on (access.rs). +futures-util = "0.3" zip = "9.0.1" toml = "1" # The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped @@ -52,5 +54,6 @@ tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] } rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] } jsonschema = { version = "0.58", default-features = false } proptest = "1" +tempfile = "3" # read_only_tools_change_nothing compares the database by its rows (spec_mcp.rs). rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/ledger-mcp/src/access.rs b/crates/ledger-mcp/src/access.rs new file mode 100644 index 0000000..f49eec7 --- /dev/null +++ b/crates/ledger-mcp/src/access.rs @@ -0,0 +1,183 @@ +//! Who is calling, what their token allows, and the record of each call +//! (plan 3.3, I-9), for books served from the data root. +//! +//! ```text +//! GOAL: a call reaches a book only with a token for that book and for +//! what the call does, and every call that reaches it is recorded +//! before it is answered. +//! SOURCE: plan 3.3 (scopes), I-9 ("every access through the server is +//! logged, and logging fails closed"), section 7's record. +//! INVARIANTS: +//! 1. The HTTP door (local.rs) puts a Caller on every request it lets +//! through; a call that reaches a book with an access log and has no +//! Caller is refused. +//! 2. A call runs only if its Caller's token has the scope it needs +//! (scopes.rs); otherwise it is refused, unrun. +//! 3. Every call, run or refused, gets one record, written before its +//! answer goes (a call that ran, on its own blocking thread while it +//! still counts as running, so a close waits for its record too); +//! if the record can't be written, the book stops taking requests +//! (SharedStore's stop) and the answer says so, a call refused as not +//! run keeping its "not run" code. +//! 4. A record's head is the journal's head after the call's own last +//! write (taken under the store's lock as it lets go), whatever the +//! call answered; none for a call that wrote nothing. +//! ``` + +use ledger_store::access::{Access, AccessLog, Client, Outcome, Principal, digest}; +use ledger_store::auth::{Scope, Token}; +use ledger_store::chain::Head; +use rmcp::service::{RequestContext, RoleServer}; +use std::sync::Arc; + +/// Who made a request: the token the door matched (INVARIANT 1). +#[derive(Clone, Debug)] +pub struct Caller { + /// The token. + pub token: Token, +} + +impl Caller { + /// As the access log names them. + pub fn principal(&self) -> Principal { + Principal { + id: self.token.id.clone(), + kind: "agent".into(), + label: self.token.name.clone(), + } + } + + /// Whether the token has `scope`. + pub fn may(&self, scope: Scope) -> bool { + self.token.scopes.contains(&scope) + } +} + +/// The caller of a request, as the door left it. +pub fn caller(context: &RequestContext) -> Option { + context + .extensions + .get::() + .and_then(|parts| parts.extensions.get::()) + .cloned() +} + +/// The client program, as it named itself at `initialize`. +fn client(context: &RequestContext) -> Option { + context.peer.peer_info().map(|info| Client { + name: info.client_info.name.clone(), + version: info.client_info.version.clone(), + }) +} + +fn request_id(context: &RequestContext) -> String { + match &context.id { + rmcp::model::NumberOrString::Number(n) => n.to_string(), + rmcp::model::NumberOrString::String(s) => s.to_string(), + } +} + +/// One call on its way through: who, what, and the log it goes into. +#[derive(Clone)] +pub(crate) struct Call { + log: Arc, + caller: Option, + client: Option, + request_id: String, + action: &'static str, + subject: String, + args_digest: Option, +} + +/// Why a call wasn't run, for its record and its answer. +pub(crate) const UNAUTHORIZED: &str = "unauthorized"; +/// The token lacks the scope. +pub(crate) const INSUFFICIENT_SCOPE: &str = "insufficient_scope"; +/// The books were closing. +pub(crate) const CLOSING: &str = "closing"; +/// The server had stopped. +pub(crate) const STOPPED: &str = "stopped"; + +/// What a refused call is told when its record couldn't be kept, or a +/// call that ran is told instead of its answer (INVARIANT 3). +pub(crate) const NOT_RECORDED: &str = "this call's access record couldn't be written, so the books server stopped taking requests, to keep every access on record: ask the human to restart it"; +/// The same, for a call that ran. +pub(crate) const RAN_NOT_RECORDED: &str = "this call ran, but its access record couldn't be written, so the books server stopped taking requests, to keep every access on record: ask the human to restart it, then check whether the call's change is in the books before trying again"; + +impl Call { + /// A call to `subject` (a tool's name, a resource's address). Its + /// arguments' digest is added later ([`Call::digested`]): hashing a + /// large call's arguments waits until it is counted as running, so a + /// close beginning meanwhile waits for it. + pub(crate) fn new( + log: &Arc, + context: &RequestContext, + action: &'static str, + subject: &str, + ) -> Self { + Self { + log: log.clone(), + caller: caller(context), + client: client(context), + request_id: request_id(context), + action, + subject: subject.to_string(), + args_digest: None, + } + } + + /// The digest of a call's arguments (blocking work: a call's + /// arguments may be megabytes). + pub(crate) fn digest_of( + args: Option<&serde_json::Map>, + ) -> Option { + args.map(|a| digest(&serde_json::Value::Object(a.clone()))) + } + + /// This call, with its arguments' digest. + pub(crate) fn digested(&mut self, args_digest: Option) { + self.args_digest = args_digest; + } + + /// The call's caller, if the door named one. + pub(crate) fn caller(&self) -> Option<&Caller> { + self.caller.as_ref() + } + + /// Record the call's outcome, on a blocking thread; Err only if it + /// couldn't be written (INVARIANT 3). + pub(crate) async fn record(&self, result: Outcome, head_after: Option) -> Result<(), ()> { + let me = self.clone(); + tokio::task::spawn_blocking(move || me.record_blocking(result, head_after)) + .await + .unwrap_or(Err(())) + } + + /// [`Call::record`], on this thread (one that may block). + pub(crate) fn record_blocking( + &self, + result: Outcome, + head_after: Option, + ) -> Result<(), ()> { + let access = Access { + principal: self + .caller + .as_ref() + .map_or_else(Principal::unknown, Caller::principal), + client: self.client.clone(), + request_id: Some(self.request_id.clone()), + action: self.action.to_string(), + subject: self.subject.clone(), + args_digest: self.args_digest.clone(), + result, + head_after, + }; + match self.log.record(&access) { + Ok(_) => Ok(()), + Err(e) => { + eprintln!("wanderbooks server: an access record couldn't be written: {e}"); + Err(()) + } + } + } +} diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs index 76560ce..6429ea0 100644 --- a/crates/ledger-mcp/src/admin.rs +++ b/crates/ledger-mcp/src/admin.rs @@ -1,5 +1,6 @@ -//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.9): how the -//! command line asks the books server to list, create and adopt books. +//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.3, 3.9): how +//! the command line asks the books server to list, create and adopt books, +//! and to make, list and revoke agents' tokens. //! //! ```text //! GOAL: the command line makes and adopts books without ever touching @@ -21,6 +22,9 @@ //! 5. Nothing in a settings file is dropped silently: entries the books //! don't know are refused unless `skip_unknown`, and its notes and //! comments are in the preview. +//! 6. A token's text is in exactly one answer, TokenCreated, to the +//! request that made it; nothing else carries it (auth.rs +//! INVARIANT 1). //! ``` use serde::{Deserialize, Serialize}; @@ -93,6 +97,69 @@ pub enum Request { #[serde(default)] skip_unknown: bool, }, + /// Make a token for an agent (plan 3.3). + TokenCreate { + /// The books it opens. + book: String, + /// Its name, unique among the books' tokens ("agent"). + name: String, + /// What it may do: books:read, books:write, books:post, + /// books:admin. + scopes: Vec, + /// End a token of the same name in the same step. + #[serde(default)] + replace: bool, + }, + /// The live tokens. + TokenList, + /// End a token. + TokenRevoke { + /// Its books. + book: String, + /// Its name. + name: String, + /// Only if it is still this one (a token just made, taken back). + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + }, +} + +/// A token's text in an answer: Debug never shows it (INVARIANT 6). +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TokenText(pub String); + +impl std::fmt::Debug for TokenText { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("TokenText(…)") + } +} + +/// A token, as listed (never its text). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenInfo { + /// Its id. + pub id: String, + /// The books it opens. + pub book: String, + /// Its name. + pub name: String, + /// What it may do. + pub scopes: Vec, + /// When it was made (UTC). + pub created: String, +} + +impl From<&ledger_store::auth::Token> for TokenInfo { + fn from(t: &ledger_store::auth::Token) -> Self { + Self { + id: t.id.clone(), + book: t.book.clone(), + name: t.name.clone(), + scopes: t.scopes.iter().map(|s| s.as_str().to_string()).collect(), + created: t.created.clone(), + } + } } /// One book, as listed. @@ -188,6 +255,17 @@ pub enum Response { Preview(AdoptPreview), /// Books adopted and open. Adopted(Adopted), + /// A token made: its text, shown this once (INVARIANT 6). + TokenCreated { + /// The token. + token: TokenInfo, + /// Its text. + text: TokenText, + }, + /// The live tokens. + Tokens(Vec), + /// A token ended. + TokenRevoked(TokenInfo), /// Refused, with why (no path in the data root: INVARIANT 3). Refused(String), } @@ -227,15 +305,20 @@ pub fn call_within( use std::io::{BufRead, Write}; let not_sent = |why: String| CallError::NotSent(why); // A list changes nothing: its only question is whether it answered. - let reads_only = matches!(request, Request::List); - let unknown = move |why: String| { - if reads_only { - CallError::Unknown(why) - } else { - CallError::Unknown(format!( - "{why}, so it may or may not have been done: `wanderbooks books list` shows the books there are" - )) + let look = match request { + Request::List | Request::TokenList => None, + Request::Create { .. } | Request::Adopt { .. } => { + Some("`wanderbooks books list` shows the books there are") } + Request::TokenCreate { .. } | Request::TokenRevoke { .. } => { + Some("`wanderbooks token list` shows the tokens there are") + } + }; + let unknown = move |why: String| match look { + None => CallError::Unknown(why), + Some(look) => CallError::Unknown(format!( + "{why}, so it may or may not have been done: {look}" + )), }; let quiet = |e: std::io::Error| match e.kind() { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => { @@ -361,9 +444,78 @@ mod tests { problem: None, }, Response::Refused("no".into()), + Response::TokenCreated { + token: TokenInfo { + id: "tok_1".into(), + book: "acme".into(), + name: "agent".into(), + scopes: vec!["books:read".into()], + created: "2026-10-19T00:00:00.000Z".into(), + }, + text: TokenText("wb_00".into()), + }, + Response::Tokens(vec![]), ] { let text = serde_json::to_string(&r).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), r); } } + + #[test] + fn token_requests_read_as_written() { + let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_create", + "book": "acme", "name": "agent", "scopes": ["books:read"]})) + .unwrap(); + assert_eq!( + r.request, + Request::TokenCreate { + book: "acme".into(), + name: "agent".into(), + scopes: vec!["books:read".into()], + replace: false, + } + ); + let shown = format!( + "{:?}", + Response::TokenCreated { + token: TokenInfo { + id: "tok_1".into(), + book: "acme".into(), + name: "agent".into(), + scopes: vec![], + created: String::new(), + }, + text: TokenText("wb_secret".into()), + } + ); + assert!(!shown.contains("wb_secret"), "{shown}"); + let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_list"})).unwrap(); + assert_eq!(r.request, Request::TokenList); + assert!( + serde_json::from_value::( + json!({"proto": 1, "op": "token_revoke", "book": "acme"}) + ) + .is_err(), + "a revoke names the token" + ); + } + + #[test] + fn a_listed_token_carries_no_text_or_hash() { + let dir = tempfile::tempdir().unwrap(); + let mut p = ledger_store::auth::Principals::open(dir.path()).unwrap(); + let book = ledger_store::root::BookId::parse("acme").unwrap(); + let (t, secret) = p + .create( + &book, + "agent", + [ledger_store::auth::Scope::Read].into(), + false, + ) + .unwrap(); + let text = serde_json::to_string(&Response::Tokens(vec![TokenInfo::from(&t)])).unwrap(); + assert!(!text.contains(&secret.expose()[3..])); + assert!(!text.contains(&t.sha256)); + assert!(text.contains("books:read")); + } } diff --git a/crates/ledger-mcp/src/lib.rs b/crates/ledger-mcp/src/lib.rs index d0a5c6d..3e73a9b 100644 --- a/crates/ledger-mcp/src/lib.rs +++ b/crates/ledger-mcp/src/lib.rs @@ -18,6 +18,7 @@ //! `/`, the home folder, or holds the books, the vault or a key is refused //! at startup. +pub mod access; pub mod admin; mod handbook; #[cfg(unix)] @@ -25,6 +26,7 @@ pub mod local; mod local_files; pub mod opening; mod policy; +pub mod scopes; mod server; mod settings; mod tin_files; diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index e6a3fea..903e21d 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -6,7 +6,8 @@ //! data root to this account alone, over sockets in the root's //! private `run/` folder, and lets go of every book cleanly. //! SOURCE: plan/served.md 3.1 (the server's sockets; the admin socket -//! creates and adopts books and can't read them), 3.4 (data +//! creates and adopts books, mints tokens, and can't read books), +//! 3.3 (bearer tokens, scopes), 3.4 (data //! layout, run/books.lock), 3.9 (local channels, peer checks, //! closing: "no new requests, the one in progress finishes, every //! writer lock is released"), D-21 and D-23 (no service: the host @@ -31,6 +32,17 @@ //! ones included), then the sessions end, the sockets are removed, //! and the process exits 0, releasing every lock. //! 7. Nothing this process sends a client names the data root. +//! 8. An MCP request reaches a book only with a bearer token that is +//! live for that book (auth/principals.jsonl; refused 401 without +//! one, 403 with another book's), and a session only with the token +//! that opened it (403). A request refused at the door is recorded +//! in that book's access log; the token rides with the request to +//! the book's tools, which check its scopes (access.rs). A session is +//! bound to its token when opened, and forgotten when it ends, is +//! found gone, or its token is revoked or replaced; past MAX_SESSIONS +//! bindings a new session is refused (an initialize), never a binding +//! dropped. Tokens are made, listed and revoked only through +//! the admin socket. //! ``` use crate::admin::{self, Request, Response}; @@ -39,6 +51,8 @@ use crate::policy::ReceiptPolicy; use crate::server::{BooksServer, Layout, SharedStore}; use crate::settings::Live; use ledger_store::Store; +use ledger_store::access::{Access, AccessLog, Outcome, Principal}; +use ledger_store::auth::{Principals, Scope, Token}; use ledger_store::root::{BookId, Root, RootError}; use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, @@ -86,6 +100,9 @@ enum Book { service: Service, /// Its store, closed when the server closes (INVARIANT 6). store: Arc, + /// Its access log, where requests refused at the door are + /// recorded too (access.rs). + access: Arc, }, /// Not opened, with why (INVARIANT 3). Failed(String), @@ -119,6 +136,12 @@ struct Books { started: std::time::Instant, /// Woken when one finishes. finished: tokio::sync::Notify, + /// The agents' tokens (auth/principals.jsonl), or why they can't be + /// read: then every MCP request is refused (INVARIANT 8). + principals: RwLock>, + /// Which token opened each MCP session: a session is used only with + /// the token that opened it (INVARIANT 8). + session_tokens: Mutex>, } impl Books { @@ -133,6 +156,71 @@ impl Books { text } + /// Change or read the principals; a log that failed to write is read + /// again before anything else is done with it (chain.rs INVARIANT 2). + fn with_principals( + &self, + f: impl FnOnce(&mut Principals) -> Result, + ) -> Response { + let mut guard = self.principals.write().unwrap_or_else(|e| e.into_inner()); + let p = match guard.as_mut() { + Ok(p) => p, + Err(why) => return Response::Refused(why.clone()), + }; + match f(p) { + Ok(r) => r, + Err(e) => { + if matches!(e, ledger_store::auth::AuthError::Log(_)) { + *guard = self.load_principals(); + } + Response::Refused(self.hide(e.to_string())) + } + } + } + + /// Forget the sessions a token opened (it was revoked or replaced: + /// they can't be used again, and won't be ended by it). + fn forget_sessions_of(&self, token_id: &str) { + self.session_tokens + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|_, owner| owner != token_id); + } + + /// Read the principals from the root's `auth/`. + fn load_principals(&self) -> Result { + let dir = self.root.auth_dir().map_err(|e| e.to_string())?; + Principals::open(&dir).map_err(|e| self.hide(e.to_string())) + } + + /// The live token whose text a request carries as its bearer, or why + /// it is refused (INVARIANT 8): the code for the access log and the + /// words for the client. + fn bearer(&self, headers: &axum::http::HeaderMap) -> Result { + // The scheme's name in any case (RFC 9110 11.1). + let presented = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split_once(' ')) + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer")) + .map(|(_, token)| token.trim()); + let Some(presented) = presented else { + return Err(( + crate::access::UNAUTHORIZED, + "these books need a token: the human makes one with `wanderbooks token create`" + .into(), + )); + }; + let guard = self.principals.read().unwrap_or_else(|e| e.into_inner()); + match guard.as_ref() { + Err(why) => Err((crate::access::UNAUTHORIZED, why.clone())), + Ok(p) => p.check(presented).cloned().ok_or(( + crate::access::UNAUTHORIZED, + "that token isn't one these books know, or it was revoked".into(), + )), + } + } + /// Open the book `id` from disk: served, or failed with why. fn open(&self, id: &BookId) -> Book { let opened = match Store::open(&self.root.book_dir(id)) { @@ -169,8 +257,13 @@ impl Books { self.import_roots.clone(), ) .map_err(|e| self.hide(e))?; + let access = Arc::new( + AccessLog::open(store.data_dir()) + .map_err(|e| format!("its access log can't be used: {e}"))?, + ); let live = Arc::new(Mutex::new(live)); let store = SharedStore::new(store); + let log = access.clone(); let shared = store.clone(); let config = StreamableHttpServerConfig::default() .with_json_response(true) @@ -184,7 +277,8 @@ impl Books { BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) .with_live_settings(live.clone()) .with_import_roots(roots.clone()) - .with_layout(layout.clone()), + .with_layout(layout.clone()) + .with_access(log.clone()), ) }, Arc::new(sessions()), @@ -193,6 +287,7 @@ impl Books { Ok(Book::Open { service, store: shared, + access, }) } @@ -235,6 +330,73 @@ impl Books { }) .collect(), ), + Request::TokenCreate { + book, + name, + scopes, + replace, + } => { + let book = match BookId::parse(&book) { + Ok(id) => id, + Err(e) => return refused(e), + }; + if !self.held().contains_key(book.as_str()) { + return Response::Refused(format!( + "there are no books called {book}: `wanderbooks books list` lists them" + )); + } + let mut wanted = std::collections::BTreeSet::new(); + for s in &scopes { + match Scope::parse(s) { + Some(scope) => { + wanted.insert(scope); + } + None => { + return Response::Refused(format!( + "{:?} isn't a scope: use books:read, books:write, books:post or books:admin", + s.chars().take(64).collect::() + )); + } + } + } + let mut replaced: Option = None; + let answer = self.with_principals(|p| { + replaced = p + .tokens() + .iter() + .find(|t| t.book == book.as_str() && t.name == name) + .map(|t| t.id.clone()) + .filter(|_| replace); + p.create(&book, &name, wanted, replace) + .map(|(token, text)| Response::TokenCreated { + token: admin::TokenInfo::from(&token), + text: admin::TokenText(text.expose().to_string()), + }) + }); + if let (Response::TokenCreated { .. }, Some(old)) = (&answer, &replaced) { + self.forget_sessions_of(old); + } + answer + } + Request::TokenList => self.with_principals(|p| { + Ok(Response::Tokens( + p.tokens().iter().map(admin::TokenInfo::from).collect(), + )) + }), + Request::TokenRevoke { book, name, id } => { + let book = match BookId::parse(&book) { + Ok(id) => id, + Err(e) => return refused(e), + }; + let answer = self.with_principals(|p| { + p.revoke_if(&book, &name, id.as_deref()) + .map(|t| Response::TokenRevoked(admin::TokenInfo::from(&t))) + }); + if let Response::TokenRevoked(t) = &answer { + self.forget_sessions_of(&t.id); + } + answer + } Request::Create { id, currency } => { let id = match BookId::parse(&id) { Ok(id) => id, @@ -677,12 +839,67 @@ impl Drop for InFlight { } } -/// `/b/{book}/mcp`: the book's MCP service. +/// The MCP session header. +const SESSION_HEADER: &str = "mcp-session-id"; + +/// The most sessions open at once (each bound to its token): past it a +/// new session is refused until one ends, never a binding forgotten +/// (INVARIANT 8; review of slice 4: clearing them let a flood unbind +/// live sessions). +const MAX_SESSIONS: usize = 100_000; + +/// A request refused at the door: recorded in the book's access log +/// (access.rs INVARIANT 3), then answered with `status` and `why`. +async fn refused_at_door( + book: &Book, + principal: Principal, + method: &axum::http::Method, + code: &'static str, + status: axum::http::StatusCode, + why: String, +) -> axum::response::Response { + use axum::response::IntoResponse; + if let Book::Open { access, store, .. } = book { + let access = access.clone(); + let record = Access { + principal, + client: None, + request_id: None, + action: "connect".into(), + subject: method.as_str().to_string(), + args_digest: None, + result: Outcome::Refused(code.into()), + head_after: None, + }; + let written = tokio::task::spawn_blocking(move || access.record(&record)).await; + if !matches!(written, Ok(Ok(_))) { + store.stop(); + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + [(REFUSED_HEADER, "not-recorded")], + crate::access::NOT_RECORDED, + ) + .into_response(); + } + } + let mut response = (status, [(REFUSED_HEADER, code)], why).into_response(); + if status == axum::http::StatusCode::UNAUTHORIZED { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderValue::from_static("Bearer realm=\"wanderbooks\""), + ); + } + response +} + +/// `/b/{book}/mcp`: the book's MCP service, for a request carrying a token +/// for that book (INVARIANT 8). async fn mcp( axum::extract::State(books): axum::extract::State>, axum::extract::Path(book): axum::extract::Path, - request: axum::extract::Request, + mut request: axum::extract::Request, ) -> axum::response::Response { + use axum::http::StatusCode; use axum::response::IntoResponse; use tower::ServiceExt; // Counted before the closing check, so closing never misses it. @@ -690,41 +907,179 @@ async fn mcp( books .last_arrival .store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst); - if books.closing.is_cancelled() { - return ( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - "the books are closing: this request wasn't run", - ) - .into_response(); - } + let closing = books.closing.is_cancelled(); let found = books.held().get(&book).cloned(); - match found { - Some(Book::Open { service, .. }) => match service.oneshot(request).await { - Ok(response) => response.map(|body| { - use http_body_util::BodyExt; - axum::body::Body::new(body.map_frame(move |frame| { - let _held = &counted; - frame - })) - }), - Err(never) => match never {}, + let (service, held) = match found { + Some(Book::Failed(why)) if !closing => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + [(REFUSED_HEADER, "not-open")], + format!("the books {book} didn't open: {why}"), + ) + .into_response(); + } + None if !closing => { + return ( + StatusCode::NOT_FOUND, + [(REFUSED_HEADER, "no-such-books")], + format!( + "there are no books called {:?}: `wanderbooks books list` lists them", + book.chars().take(64).collect::() + ), + ) + .into_response(); + } + Some(b @ Book::Open { .. }) => match &b { + Book::Open { service, .. } => (service.clone(), b.clone()), + Book::Failed(_) => unreachable!(), }, - Some(Book::Failed(why)) => ( - axum::http::StatusCode::SERVICE_UNAVAILABLE, - [(REFUSED_HEADER, "not-open")], - format!("the books {book} didn't open: {why}"), + _ => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "the books are closing: this request wasn't run", + ) + .into_response(); + } + }; + let method = request.method().clone(); + let token = books.bearer(request.headers()); + let principal = |t: &Token| Principal { + id: t.id.clone(), + kind: "agent".into(), + label: t.name.clone(), + }; + if closing { + let who = token + .as_ref() + .map_or_else(|_| Principal::unknown(), principal); + return refused_at_door( + &held, + who, + &method, + crate::access::CLOSING, + StatusCode::SERVICE_UNAVAILABLE, + "the books are closing: this request wasn't run".into(), ) - .into_response(), - None => ( - axum::http::StatusCode::NOT_FOUND, - [(REFUSED_HEADER, "no-such-books")], - format!( - "there are no books called {:?}: `wanderbooks books list` lists them", - book.chars().take(64).collect::() - ), - ) - .into_response(), + .await; } + let token = match token { + Ok(t) => t, + Err((code, why)) => { + return refused_at_door( + &held, + Principal::unknown(), + &method, + code, + StatusCode::UNAUTHORIZED, + why, + ) + .await; + } + }; + if token.book != book { + return refused_at_door( + &held, + principal(&token), + &method, + "wrong_books", + StatusCode::FORBIDDEN, + format!("this token is for the books {}, not {book}", token.book), + ) + .await; + } + let session = request + .headers() + .get(SESSION_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + // A session is bound when it is opened (below): one bound to another + // token is refused; one never bound isn't one this server opened, and + // is left to the MCP service, which doesn't know it either (and says + // so, which a client takes as "open a new one"). + let (elsewhere, full) = { + let bound = books + .session_tokens + .lock() + .unwrap_or_else(|e| e.into_inner()); + let elsewhere = session + .as_ref() + .and_then(|sid| bound.get(sid)) + .is_some_and(|owner| *owner != token.id); + (elsewhere, session.is_none() && bound.len() >= MAX_SESSIONS) + }; + // The cap is on opening sessions: a request opens one only if it is an + // initialize (a 2026-07-28 call without a session opens none). + let full = full && { + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, MAX_BODY) + .await + .unwrap_or_default(); + let opens = serde_json::from_slice::(&bytes) + .ok() + .is_some_and(|m| m.get("method").and_then(|v| v.as_str()) == Some("initialize")); + request = axum::extract::Request::from_parts(parts, axum::body::Body::from(bytes)); + opens + }; + if full { + return refused_at_door( + &held, + principal(&token), + &method, + "too_many_sessions", + StatusCode::SERVICE_UNAVAILABLE, + "the books server has too many sessions open: end some, then try again".into(), + ) + .await; + } + if elsewhere { + return refused_at_door( + &held, + principal(&token), + &method, + "session_token", + StatusCode::FORBIDDEN, + "this session was opened with another token".into(), + ) + .await; + } + let token_id = token.id.clone(); + request + .extensions_mut() + .insert(crate::access::Caller { token }); + let response = match service.oneshot(request).await { + Ok(response) => response, + Err(never) => match never {}, + }; + // A session the answer opened is bound to the token that opened it; + // one ended, or found gone, is forgotten. + { + let mut bound = books + .session_tokens + .lock() + .unwrap_or_else(|e| e.into_inner()); + if session.is_none() + && let Some(sid) = response + .headers() + .get(SESSION_HEADER) + .and_then(|v| v.to_str().ok()) + { + bound.insert(sid.to_string(), token_id); + } + let ended = method == axum::http::Method::DELETE && response.status().is_success(); + let gone = response.status() == StatusCode::NOT_FOUND; + if (ended || gone) + && let Some(sid) = &session + { + bound.remove(sid); + } + } + response.map(|body| { + use http_body_util::BodyExt; + axum::body::Body::new(body.map_frame(move |frame| { + let _held = &counted; + frame + })) + }) } /// Anything but `/b/{book}/mcp`. @@ -900,7 +1255,16 @@ pub fn run( finished: tokio::sync::Notify::new(), last_arrival: std::sync::atomic::AtomicU64::new(0), started: std::time::Instant::now(), + principals: RwLock::new(Err(String::new())), + session_tokens: Mutex::new(BTreeMap::new()), }); + { + let loaded = books.load_principals(); + if let Err(why) = &loaded { + eprintln!("wanderbooks server: agents' tokens can't be read: {why}"); + } + *books.principals.write().unwrap_or_else(|e| e.into_inner()) = loaded; + } // INVARIANT 3: every book tried before ready. for id in root.list_books().map_err(|e| e.to_string())? { let book = books.open(&id); diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs new file mode 100644 index 0000000..82c0980 --- /dev/null +++ b/crates/ledger-mcp/src/scopes.rs @@ -0,0 +1,359 @@ +//! What each tool needs of a token (plan 3.3). +//! +//! ```text +//! GOAL: a token does only what its scopes allow: reading needs +//! books:read, changing what isn't posted books:write, posting and +//! what is as final books:post, the access log books:admin. +//! SOURCE: plan 3.3's scope table ("books:post: confirmed post, close, +//! reconcile, dispose, run_*, settings commit"). +//! INVARIANTS: +//! 1. Every tool is in exactly one row of TABLE (test). +//! 2. A tool that changes anything (no readOnlyHint) needs more than +//! books:read; a read-only one needs books:read, except reading the +//! access log, which needs books:admin (test). +//! 3. A tool with a `confirmed` argument (a preview, then the act) +//! needs books:write to preview and books:post when confirmed is +//! true. +//! 4. What gates posting needs posting's scope: setting an approval +//! rule, retiring any convention (one might be an approval rule), +//! and approving an entry need books:post. Every other tool needs one +//! scope whatever it is given. +//! 5. No token below books:admin gets a full TIN: until P7 moves TINs +//! to the human pages, the tools that can put one out (vendor_1099 +//! with include_tins, export_cpa_package, get_w9) need books:admin, +//! and sealing one into the vault (set_tin, set_w9, which can replace +//! the one there) needs books:post (review of slice 4). +//! ``` + +use ledger_store::auth::Scope; + +/// What a tool needs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Needs { + /// This scope, always. + Always(Scope), + /// books:write to preview, books:post when `confirmed` is true + /// (INVARIANT 3). + PostWhenConfirmed, + /// books:post for an approval rule (the control that gates posting), + /// books:write for any other convention (INVARIANT 4). + PostForApprovalRules, +} + +use Needs::{Always, PostForApprovalRules, PostWhenConfirmed}; +const R: Needs = Always(Scope::Read); +const W: Needs = Always(Scope::Write); +const P: Needs = Always(Scope::Post); +const A: Needs = Always(Scope::Admin); +const C: Needs = PostWhenConfirmed; +const K: Needs = PostForApprovalRules; + +/// Every tool and what it needs (INVARIANT 1). +pub const TABLE: [(&str, Needs); 109] = [ + ("books.handbook", R), + ("books.start", R), + // Settings are committed at once: the settings commit of the plan. + ("books.set_setting", P), + ("books.import_settings", C), + ("books.export_settings", R), + ("books.conventions", R), + ("books.set_convention", K), + ("books.retire_convention", P), + ("books.record_decision", W), + ("books.decisions", R), + ("books.raise_item", W), + ("books.resolve_item", W), + ("books.open_items", R), + ("books.draft_from_template", W), + ("books.approve_entry", P), + ("books.chart", R), + ("books.add_account", W), + ("books.trial_balance", R), + ("books.profit_and_loss", R), + ("books.balance_sheet", R), + ("books.cash_flow", R), + ("books.draft_entry", W), + ("books.add_line", W), + ("books.post_entry", C), + ("books.reverse_entry", C), + ("books.attach_receipt", W), + ("books.file_document", W), + ("books.link_receipt", W), + ("books.unlink_receipt", W), + ("books.withdraw_attachment", W), + ("books.link_attachment", W), + ("books.unlink_attachment", W), + ("books.attachments", R), + ("books.list_receipts", R), + ("books.get_receipt", R), + ("books.get_entry", R), + ("books.search_entries", R), + ("books.show", R), + // It may rebuild the database from the journal: not read-only. + ("books.verify", W), + ("books.digest", R), + ("books.import_bank_csv", W), + ("books.create_schedule", W), + ("books.list_schedules", R), + ("books.run_schedules", C), + ("books.create_recurring", W), + ("books.list_recurring", R), + ("books.run_recurring", P), + ("books.record_payout", W), + ("books.list_vendors", R), + ("books.set_vendor_1099", W), + ("books.set_entry_vendor", W), + // Until P7 these can put full TINs out (with include_tins, or a W-9 + // itself): only an admin token, never a plain one (plan 3.3, "no + // scope unwraps the vault", holds for every other scope). + ("books.vendor_1099", A), + ("books.chart_template", R), + ("books.tax_categories", R), + ("books.renumber_account", P), + ("books.label_asset", W), + ("books.correct_asset", P), + ("books.set_asset_tax_basis", W), + ("books.asset_labels", R), + ("books.settings", R), + ("books.roles", R), + ("books.assign_role", P), + ("books.set_tax_category", W), + ("books.import_chart_csv", W), + ("books.import_local_file", W), + ("books.export", R), + ("books.register_asset", W), + ("books.asset_schedule", R), + ("books.list_assets", R), + ("books.run_depreciation", P), + ("books.dispose_asset", C), + ("books.create_period", W), + ("books.close_period", C), + ("books.list_periods", R), + ("books.mark_cleared", W), + ("books.reconcile", C), + ("books.record_statement", W), + ("books.statements", R), + ("books.reconciliations", R), + ("books.void_statement", P), + ("books.void_reconciliation", P), + ("books.add_customer", W), + ("books.add_vendor", W), + ("books.add_party", W), + ("books.update_party", W), + ("books.set_party_roles", W), + // Sealing a TIN can replace the one in the vault. + ("books.set_w9", P), + ("books.set_tin", P), + ("books.get_w9", A), + ("books.purge_tin", C), + ("books.set_w4", W), + ("books.parties", R), + ("books.party", R), + ("books.set_entry_party", W), + // These post at once, unchecked. + ("books.create_invoice", P), + ("books.create_bill", P), + ("books.receive_payment", P), + ("books.pay_bill", P), + ("books.ar_aging", R), + ("books.ap_aging", R), + ("books.set_fx_rate", W), + ("books.run_payroll", P), + ("books.remit_payroll_taxes", P), + ("books.list_payroll_runs", R), + ("books.add_class", W), + ("books.list_classes", R), + ("books.profit_and_loss_by_class", R), + ("books.export_cpa_package", A), + ("books.access_log", A), +]; + +/// What `tool` needs, given its arguments; None for a tool this server +/// doesn't have. +pub fn needs( + tool: &str, + args: Option<&serde_json::Map>, +) -> Option { + let (_, needs) = TABLE.iter().find(|(name, _)| *name == tool)?; + Some(match needs { + Always(scope) => *scope, + PostWhenConfirmed => { + let confirmed = args + .and_then(|a| a.get("confirmed")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if confirmed { Scope::Post } else { Scope::Write } + } + PostForApprovalRules => { + let kind = args + .and_then(|a| a.get("kind")) + .and_then(serde_json::Value::as_str); + if kind == Some("approval_rule") { + Scope::Post + } else { + Scope::Write + } + } + }) +} + +/// Whether a token with `scopes` may list `tool` (it can call it in some +/// form: a tool that posts when confirmed shows to a books:write token, +/// which may preview). +pub fn shown(tool: &str, scopes: &std::collections::BTreeSet) -> bool { + match TABLE.iter().find(|(name, _)| *name == tool) { + Some((_, Always(scope))) => scopes.contains(scope), + Some((_, PostWhenConfirmed | PostForApprovalRules)) => { + scopes.contains(&Scope::Write) || scopes.contains(&Scope::Post) + } + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::BooksServer; + use serde_json::json; + + #[test] + fn every_tool_is_in_the_table_once() { + let tools = BooksServer::tool_router().list_all(); + let mut names: Vec<&str> = TABLE.iter().map(|(n, _)| *n).collect(); + names.sort_unstable(); + let len = names.len(); + names.dedup(); + assert_eq!(names.len(), len, "a tool is in the table twice"); + let mut have: Vec = tools.iter().map(|t| t.name.to_string()).collect(); + have.sort(); + assert_eq!(names, have, "the table and the tools differ"); + } + + #[test] + fn only_read_only_tools_need_no_more_than_read() { + for tool in BooksServer::tool_router().list_all() { + let read_only = tool + .annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false); + let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap(); + let want = if !read_only { + *needs != R + } else if tool.name == "books.access_log" { + *needs == A + } else { + *needs == R + }; + assert!( + want, + "{}: read-only {read_only}, needs {needs:?}", + tool.name + ); + } + } + + #[test] + fn a_tool_that_takes_confirmed_posts_only_when_confirmed() { + // Exactly the tools whose arguments have `confirmed`. + for tool in BooksServer::tool_router().list_all() { + let takes = tool + .input_schema + .get("properties") + .and_then(|p| p.as_object()) + .is_some_and(|p| p.contains_key("confirmed")); + let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap(); + assert_eq!(takes, *needs == C, "{}", tool.name); + let takes_kind = tool + .input_schema + .get("properties") + .and_then(|p| p.as_object()) + .is_some_and(|p| p.contains_key("kind")); + if *needs == K { + assert!(takes_kind, "{}", tool.name); + } + } + let args = |v: serde_json::Value| v.as_object().cloned(); + assert_eq!(needs("books.post_entry", None), Some(Scope::Write)); + assert_eq!( + needs( + "books.post_entry", + args(json!({"confirmed": false})).as_ref() + ), + Some(Scope::Write) + ); + assert_eq!( + needs( + "books.post_entry", + args(json!({"confirmed": true})).as_ref() + ), + Some(Scope::Post) + ); + // Only a real true confirms. + assert_eq!( + needs( + "books.post_entry", + args(json!({"confirmed": "true"})).as_ref() + ), + Some(Scope::Write) + ); + assert_eq!( + needs( + "books.create_invoice", + args(json!({"confirmed": false})).as_ref() + ), + Some(Scope::Post), + "a tool that posts at once needs books:post whatever it is given" + ); + assert_eq!(needs("books.access_log", None), Some(Scope::Admin)); + assert_eq!(needs("books.nonesuch", None), None); + // INVARIANT 4. + assert_eq!( + needs( + "books.set_convention", + args(json!({"kind": "approval_rule"})).as_ref() + ), + Some(Scope::Post) + ); + assert_eq!( + needs( + "books.set_convention", + args(json!({"kind": "policy"})).as_ref() + ), + Some(Scope::Write) + ); + assert_eq!(needs("books.set_convention", None), Some(Scope::Write)); + assert_eq!(needs("books.retire_convention", None), Some(Scope::Post)); + assert_eq!(needs("books.approve_entry", None), Some(Scope::Post)); + // INVARIANT 5. + for tool in [ + "books.vendor_1099", + "books.export_cpa_package", + "books.get_w9", + ] { + assert_eq!(needs(tool, None), Some(Scope::Admin), "{tool}"); + } + for tool in ["books.set_tin", "books.set_w9", "books.purge_tin"] { + let confirmed = args(json!({"confirmed": true})); + assert_eq!(needs(tool, confirmed.as_ref()), Some(Scope::Post), "{tool}"); + } + } + + #[test] + fn a_token_is_shown_what_it_can_call() { + let set = |s: &[Scope]| s.iter().copied().collect::>(); + let read = set(&[Scope::Read]); + assert!(shown("books.chart", &read)); + assert!(!shown("books.post_entry", &read)); + assert!(!shown("books.draft_entry", &read)); + assert!(!shown("books.access_log", &read)); + let write = set(&[Scope::Read, Scope::Write]); + assert!(shown("books.post_entry", &write), "it may preview"); + assert!(!shown("books.create_invoice", &write)); + let post = set(&[Scope::Post]); + assert!(shown("books.post_entry", &post)); + assert!(!shown("books.chart", &post)); + assert!(shown("books.access_log", &set(&[Scope::Admin]))); + assert!(!shown("books.nonesuch", &set(&Scope::ALL))); + } +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index eb16bd8..4bd96c6 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -71,6 +71,10 @@ pub struct BooksServer { settings: Arc>, import_roots: Arc, layout: Arc, + /// The book's access log, for books served from the data root: every + /// call is then checked against its caller's token and recorded + /// (access.rs). None for a books dir served over stdio. + access: Option>, } /// Where a book's own files live around its data dir. @@ -188,7 +192,9 @@ impl SharedStore { self.stopped.load(std::sync::atomic::Ordering::SeqCst) } - fn stop(&self) { + /// Stop taking requests (INVARIANT 1): after an internal error, or an + /// access record that couldn't be written (access.rs INVARIANT 3). + pub(crate) fn stop(&self) { self.stopped .store(true, std::sync::atomic::Ordering::SeqCst); } @@ -219,6 +225,8 @@ const INSIDE_BOOKS: &str = "that path is inside the books' folder, whose files a pub struct StoreGuard<'a> { guard: tokio::sync::MutexGuard<'a, Store>, shared: &'a SharedStore, + /// The journal's head when the lock was taken. + before: (u64, String), _not_send: std::marker::PhantomData<*const ()>, } @@ -227,6 +235,12 @@ impl Drop for StoreGuard<'_> { if std::thread::panicking() { self.shared.stop(); } + // A write while it was held: the head after it, for the call's + // access record (taken under the same lock, so it is this call's). + let after = self.guard.head(); + if after != self.before { + WROTE.with(|w| *w.borrow_mut() = Some(after)); + } } } @@ -256,7 +270,7 @@ where F: FnOnce() -> std::pin::Pin + Send>> + Send + 'static, { if shared.stopped() { - return Err(rmcp::ErrorData::internal_error(STOPPED, None)); + return Err(not_run_stopped()); } // SharedStore INVARIANT 3: counted before the closing check, so a // close never misses it; the count rides in the blocking task, which @@ -318,6 +332,21 @@ impl Drop for Running { /// open (the agents' bridge does). pub const NOT_RUN_CLOSING_CODE: i32 = -32050; +/// The error code of a request not run because the server had stopped +/// (SharedStore INVARIANT 2), told apart from one that ran and failed. +pub const NOT_RUN_STOPPED_CODE: i32 = -32051; + +fn not_run_stopped() -> rmcp::ErrorData { + rmcp::ErrorData::new(rmcp::model::ErrorCode(NOT_RUN_STOPPED_CODE), STOPPED, None) +} + +/// A tool's answer that is a refusal in words. +fn tool_error(text: String) -> rmcp::model::CallToolResponse { + rmcp::model::CallToolResponse::Complete(rmcp::model::CallToolResult::error(vec![ + rmcp::model::ContentBlock::text(text), + ])) +} + /// What a request refused while closing is told. pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run"; @@ -528,9 +557,17 @@ impl BooksServer { }))), import_roots: Arc::new(ImportRoots::defaults()), layout: Arc::new(Layout::BooksDir), + access: None, } } + /// Check every call against its caller's token, and record it in + /// `log` (access.rs). + pub fn with_access(mut self, log: Arc) -> Self { + self.access = Some(log); + self + } + /// Serve a book kept in the data root (`Layout::Root`) rather than /// in a books dir. pub fn with_layout(mut self, layout: Layout) -> Self { @@ -618,7 +655,7 @@ impl BooksServer { /// Every tool the server serves. pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter { - Self::core_router() + Self::kept_router() + Self::core_router() + Self::kept_router() + Self::access_router() } /// The settings in force for this request (reloaded at its start, in @@ -1064,9 +1101,11 @@ impl BooksServer { // "Today" from here on is the zone in force now (a change another // session made while this request waited included). TIMEZONE.with(|z| *z.borrow_mut() = timezone); + let before = guard.head(); Ok(StoreGuard { guard, shared: &self.store, + before, _not_send: std::marker::PhantomData, }) } @@ -2680,6 +2719,9 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa } thread_local! { + /// The journal's head after the last write made by the request + /// running on this thread (StoreGuard's drop), for its access record. + static WROTE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; /// The company's time zone for the request running on this thread /// (set at its start from the settings in force: one version per /// request), so every "today" in it agrees. @@ -2848,6 +2890,8 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec { // Tools // --------------------------------------------------------------------------- +#[path = "tools/access.rs"] +mod access_tools; #[path = "tools/kept.rs"] mod kept; pub use kept::SettingKeyOut; @@ -9560,38 +9604,204 @@ impl ServerHandler for BooksServer { request: rmcp::model::CallToolRequestParams, context: RequestContext, ) -> Result { + use crate::access::{self, Call}; + use ledger_store::access::Outcome; + let mut call = self + .access + .as_ref() + .map(|log| Call::new(log, &context, "tool", &request.name)); + let outcome_of = |out: &Result| match out { + Ok(rmcp::model::CallToolResponse::Complete(r)) if r.is_error != Some(true) => { + Outcome::Ok + } + _ => Outcome::Error, + }; + // A refused call's record carries its arguments' digest too, + // worked out off the async threads. + let refused_digest = |args: Option>| async move { + tokio::task::spawn_blocking(move || Call::digest_of(args.as_ref())) + .await + .ok() + .flatten() + }; // Closing: refused as a protocol error, so a client knows it // didn't run (SharedStore INVARIANT 3). if self.store.closing() { - return Err(not_run_closing()); + if let Some(c) = &mut call { + c.digested(refused_digest(request.arguments.clone()).await); + } + return self + .refuse(call.as_ref(), access::CLOSING, Err(not_run_closing())) + .await; + } + if self.store.stopped() { + if let Some(c) = &mut call { + c.digested(refused_digest(request.arguments.clone()).await); + } + return self + .refuse( + call.as_ref(), + access::STOPPED, + Ok(tool_error(STOPPED.to_string())), + ) + .await; + } + let needs = crate::scopes::needs(&request.name, request.arguments.as_ref()); + // access.rs INVARIANTS 1, 2. + if let Some(call) = &mut call { + let refusal = match (call.caller(), needs) { + (None, _) => Some(( + access::UNAUTHORIZED, + "this call came without a token for these books".to_string(), + )), + // A tool no table names isn't run (fail closed). + (Some(_), None) => Some(( + "unknown_tool", + format!( + "there is no tool {:?}", + request.name.chars().take(64).collect::() + ), + )), + (Some(caller), Some(scope)) if !caller.may(scope) => Some(( + access::INSUFFICIENT_SCOPE, + format!( + "this agent's token can't do that: {} needs {scope}, and its token ({:?}) has {}. Only the human can give a token more (`wanderbooks token create`).", + request.name, + caller.token.name, + caller + .token + .scopes + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ), + )), + _ => None, + }; + if let Some((code, text)) = refusal { + call.digested(refused_digest(request.arguments.clone()).await); + return self.refuse(Some(call), code, Ok(tool_error(text))).await; + } } // Pick up an edited settings file before the tool runs. self.live().refresh(); let timezone = self.settings().timezone.clone(); let server = self.clone(); + // The journal's head once a call that may change the books has + // run, for its record. + // The call's record is written on its own blocking thread, before + // it counts as done: a close waits for the record too (access.rs + // INVARIANT 3). + let running_call = call.clone(); + // Kept for the record of a call the server stopped before running. + let request_args = if call.is_some() { + request.arguments.clone() + } else { + None + }; let result = run_blocking(&self.store, move || { Box::pin(async move { + WROTE.with(|w| w.borrow_mut().take()); + // Counted as running by now (run_blocking): hashed here. + let digest = running_call + .as_ref() + .and_then(|_| Call::digest_of(request.arguments.as_ref())); let tcc = rmcp::handler::server::tool::ToolCallContext::new(&server, request, context); let router = Self::tool_router(); - in_timezone_async(timezone, router.call(tcc)).await + use futures_util::FutureExt; + let out = + std::panic::AssertUnwindSafe(in_timezone_async(timezone, router.call(tcc))) + .catch_unwind() + .await; + // The head after this call's own last write, if it wrote, + // whatever it answered. + let wrote = WROTE + .with(|w| w.borrow_mut().take()) + .map(|(seq, hash)| ledger_store::chain::Head { seq, hash }); + let out = match out { + Ok(out) => out, + Err(panic) => { + // It ran and failed part way: recorded as an error + // with what it wrote, while still counted as + // running; then the panic goes on and stops the + // server (SharedStore INVARIANT 1). + if let Some(mut c) = running_call { + c.digested(digest); + let _ = c.record_blocking(Outcome::Error, wrote); + } + std::panic::resume_unwind(panic) + } + }; + let recorded = running_call.map(|mut c| { + c.digested(digest); + c.record_blocking(outcome_of(&out), wrote) + }); + (out, recorded) }) }) .await; - // A failed run (the server stopped, or this request panicked) is a - // plain refusal, redacted like every other. - let result = match result { - Ok(out) => out, + match result { + Ok((out, None)) => redact_refusal(out), + Ok((out, Some(Ok(())))) => redact_refusal(out), + Ok((_, Some(Err(())))) => { + // access.rs INVARIANT 3. + self.store.stop(); + Ok(tool_error(crate::access::RAN_NOT_RECORDED.to_string())) + } // Not run because the books are closing: a protocol error, so - // the client knows (SharedStore INVARIANT 3). - Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e), - Err(e) => Ok(rmcp::model::CallToolResponse::Complete( - rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text( - e.message.to_string(), - )]), - )), - }; - redact_refusal(result) + // the client knows (SharedStore INVARIANT 3). (Its arguments + // went with the request: no digest.) + Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => { + self.refuse(call.as_ref(), access::CLOSING, Err(e)).await + } + // Not run: the server had stopped by the time it would have. + Err(e) if e.code.0 == NOT_RUN_STOPPED_CODE => { + if let Some(c) = &mut call { + c.digested(refused_digest(request_args.clone()).await); + } + self.refuse( + call.as_ref(), + access::STOPPED, + Ok(tool_error(STOPPED.to_string())), + ) + .await + } + // It panicked part way (recorded as it went): a plain refusal, + // redacted like every other. + Err(e) => redact_refusal(Ok(tool_error(e.message.to_string()))), + } + } + + async fn list_tools( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + let mut tools = Self::tool_router().list_all(); + // A token is shown what it can call (scopes.rs); the list then + // differs by caller, so it may be cached only for this one. + if self.access.is_some() { + let scopes = crate::access::caller(&context) + .map(|c| c.token.scopes) + .unwrap_or_default(); + tools.retain(|t| crate::scopes::shown(&t.name, &scopes)); + } else { + // Books served over stdio keep no access log: not offered. + tools.retain(|t| t.name != "books.access_log"); + } + let hints = context + .protocol_version() + .is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28); + Ok(rmcp::model::ListToolsResult { + result_type: Some(rmcp::model::ResultType::COMPLETE), + tools, + meta: None, + next_cursor: None, + ttl_ms: hints.then_some(0), + cache_scope: hints.then_some(rmcp::model::CacheScope::Private), + }) } fn get_info(&self) -> ServerConfig { @@ -9625,8 +9835,101 @@ impl ServerHandler for BooksServer { async fn read_resource( &self, request: ReadResourceRequestParams, - _context: RequestContext, + context: RequestContext, ) -> Result { + use crate::access::{self, Call}; + use ledger_store::access::Outcome; + // Reading a resource is reading the books: books:read, recorded + // (access.rs). + let call = self + .access + .as_ref() + .map(|log| Call::new(log, &context, "resource", &request.uri)); + let result = self.resource(request, call.as_ref()).await; + if let Some(call) = &call { + let outcome = match &result { + Ok(_) => Outcome::Ok, + Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => { + Outcome::Refused(access::CLOSING.into()) + } + Err(e) if e.message == access::INSUFFICIENT_SCOPE => { + Outcome::Refused(access::INSUFFICIENT_SCOPE.into()) + } + Err(e) if e.message == access::UNAUTHORIZED => { + Outcome::Refused(access::UNAUTHORIZED.into()) + } + Err(_) => Outcome::Error, + }; + if call.record(outcome, None).await.is_err() { + self.store.stop(); + // Not run stays so; the words say why. + return Err(match result { + Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => rmcp::ErrorData::new( + e.code, + format!("{}; {}", e.message, access::NOT_RECORDED), + None, + ), + _ => rmcp::ErrorData::internal_error(access::NOT_RECORDED, None), + }); + } + } + result + } +} + +impl BooksServer { + /// Record a call refused unrun, then give its answer (access.rs + /// INVARIANT 3). + async fn refuse( + &self, + call: Option<&crate::access::Call>, + code: &str, + answer: Result, + ) -> Result { + if let Some(call) = call + && call + .record(ledger_store::access::Outcome::Refused(code.into()), None) + .await + .is_err() + { + self.store.stop(); + // A call refused as not run stays so (the bridge sends it + // again when the books are back); the words say why. + return match answer { + Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => Err(rmcp::ErrorData::new( + e.code, + format!("{}; {}", e.message, crate::access::NOT_RECORDED), + None, + )), + _ => Ok(tool_error(crate::access::NOT_RECORDED.to_string())), + }; + } + answer + } + + /// A resource's contents, once its caller may read the books. + async fn resource( + &self, + request: ReadResourceRequestParams, + call: Option<&crate::access::Call>, + ) -> Result { + if let Some(call) = call { + match call.caller() { + None => { + return Err(rmcp::ErrorData::invalid_request( + crate::access::UNAUTHORIZED, + None, + )); + } + Some(c) if !c.may(ledger_store::auth::Scope::Read) => { + return Err(rmcp::ErrorData::invalid_request( + crate::access::INSUFFICIENT_SCOPE, + None, + )); + } + Some(_) => {} + } + } self.live().refresh(); let timezone = self.settings().timezone.clone(); let uri = request.uri.clone(); @@ -10082,7 +10385,7 @@ mod tests { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 108] = [ + let table: [(&str, Hints); 109] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -10191,6 +10494,7 @@ mod tests { ("books.list_classes", R), ("books.profit_and_loss_by_class", R), ("books.export_cpa_package", W), + ("books.access_log", R), ]; let tools = BooksServer::tool_router().list_all(); assert_eq!(tools.len(), table.len()); diff --git a/crates/ledger-mcp/src/tools/access.rs b/crates/ledger-mcp/src/tools/access.rs new file mode 100644 index 0000000..2be2ddb --- /dev/null +++ b/crates/ledger-mcp/src/tools/access.rs @@ -0,0 +1,114 @@ +//! `books.access_log`: who reached the books through the server, and what +//! came of it (plan I-9, 4.x ops; books:admin). +//! +//! ```text +//! GOAL: the owner, through their agent, can read the access log, newest +//! first, narrowed to a caller, an action or an outcome. +//! SOURCE: plan I-9 ("readable through books.access_log (admin)"). +//! INVARIANTS: +//! 1. It reads the log whole and checks its chain before answering; a +//! damaged log is reported, never shown in part as if whole. +//! 2. It answers at most MAX_RECORDS records, newest first, and says +//! whether there are more. +//! ``` + +use super::*; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The most records one call returns (INVARIANT 2). +const MAX_RECORDS: usize = 500; +/// How many it returns when not told. +const DEFAULT_RECORDS: usize = 50; + +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct AccessLogParams { + /// How many records, newest first: default 50, at most 500. + #[serde(default)] + pub limit: Option, + /// Only records before this one (its seq), to page back. + #[serde(default)] + pub before: Option, + /// Only calls by the token of this name. + #[serde(default)] + pub token: Option, + /// Only this action: tool, resource, or connect (a request refused at + /// the door: no token, another book's, another session's). + #[serde(default)] + pub action: Option, + /// Only this outcome: ok, error or refused. + #[serde(default)] + pub outcome: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct AccessLogOut { + /// The records, newest first. + pub records: Vec, + /// Records in the whole log. + pub total: u64, + /// Whether older records match too (page back with `before`). + pub more: bool, + /// Where the log's chain ends: its last seq and hash. + pub head: serde_json::Value, +} + +#[tool_router(router = access_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.access_log", + title = "Access log", + description = "Who reached these books through the server, newest first: each call's token, client, tool or resource, a digest of its arguments, and whether it ran, failed or was refused. Narrow it by token name, action (tool, resource, connect: refused at the door) or outcome (ok, error, refused); page back with before. Needs books:admin.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn access_log( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + if self.access.is_none() { + return Err("these books keep no access log: only books served from the books' private folder do".into()); + } + let limit = p.limit.unwrap_or(DEFAULT_RECORDS); + if limit == 0 || limit > MAX_RECORDS { + return Err(format!("limit is 1 to {MAX_RECORDS}")); + } + if let Some(o) = &p.outcome + && !["ok", "error", "refused"].contains(&o.as_str()) + { + return Err("outcome is ok, error or refused".into()); + } + let dir = self.lock()?.data_dir().to_path_buf(); + // INVARIANT 1. + let (records, head) = ledger_store::access::read(&dir) + .map_err(|e| format!("the access log can't be read: {e}"))?; + let total = records.len() as u64; + let mut matching = records.into_iter().rev().filter(|r| { + p.before.is_none_or(|b| r.seq < b) + && p.token + .as_ref() + .is_none_or(|t| &r.access.principal.label == t) + && p.action.as_ref().is_none_or(|a| &r.access.action == a) + && p.outcome.as_ref().is_none_or(|o| { + matches!( + (o.as_str(), &r.access.result), + ("ok", ledger_store::access::Outcome::Ok) + | ("error", ledger_store::access::Outcome::Error) + | ("refused", ledger_store::access::Outcome::Refused(_)) + ) + }) + }); + let out: Vec = matching + .by_ref() + .take(limit) + .map(|r| serde_json::to_value(r).expect("a record serializes")) + .collect(); + let more = matching.next().is_some(); + Ok(Json(AccessLogOut { + records: out, + total, + more, + head: serde_json::json!({"seq": head.seq, "hash": head.hash}), + })) + } +} diff --git a/crates/ledger-store/Cargo.toml b/crates/ledger-store/Cargo.toml index 873c002..7d571a0 100644 --- a/crates/ledger-store/Cargo.toml +++ b/crates/ledger-store/Cargo.toml @@ -16,6 +16,9 @@ rust_decimal = "1" thiserror = "2" sha2 = "0.10" hex = "0.4" +getrandom = { version = "0.2", features = ["std"] } +subtle = "2" +argon2 = "0.6" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/ledger-store/src/access.rs b/crates/ledger-store/src/access.rs new file mode 100644 index 0000000..b91de43 --- /dev/null +++ b/crates/ledger-store/src/access.rs @@ -0,0 +1,484 @@ +//! A book's access log (plan I-9, 3.4: `books//access.jsonl`): one +//! record for every call that reaches the book through the server, +//! refusals included. +//! +//! ```text +//! GOAL: the owner can see who reached the books, when, what they asked +//! for and what came of it, and a record can't be changed or +//! dropped unseen. +//! SOURCE: plan I-9 ("every access through the server is logged, and +//! logging fails closed"), 3.4 (hash-chained, own genesis +//! "wanderbooks-access-v1"), the access-log record in section 7. +//! INVARIANTS: +//! 1. The log is a chain (chain.rs) with its own genesis: a record is +//! acknowledged only once it is on disk, and a changed or dropped +//! one is found when the log is read. +//! 2. A record holds what the call was, never what it carried: the +//! arguments are a SHA-256 of their canonical JSON with what may be a +//! TIN masked first (digest: the TIN shapes it knows), and every text a client gave (its +//! name and version, a request id, the tool or resource it named) +//! has any full TIN masked and is then cut to 64 characters. +//! 3. Records go in one at a time, in the order they are made. +//! ``` + +use crate::chain::{self, ChainError, ChainLog, Head}; +use crate::journal::{canonical_json, sha256_hex}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::Path; +use std::sync::Mutex; + +/// The access log, in a book's folder. +pub const ACCESS_FILE: &str = "access.jsonl"; +/// Its genesis text. +pub const ACCESS_GENESIS: &str = "wanderbooks-access-v1"; +/// The longest text a client asserts that a record keeps. +pub const MAX_ASSERTED: usize = 64; + +/// Who made a call. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Principal { + /// The token's id; empty when there was no token, or none that + /// matched. + pub id: String, + /// `agent`, or `unknown` when no token matched. + pub kind: String, + /// The token's name. + pub label: String, +} + +impl Principal { + /// A caller with no token that matched. + pub fn unknown() -> Self { + Self { + id: String::new(), + kind: "unknown".into(), + label: String::new(), + } + } +} + +/// The client program, as it named itself (asserted, never checked). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Client { + /// Its name. + pub name: String, + /// Its version. + pub version: String, +} + +/// What came of a call. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// It ran and answered. + Ok, + /// It ran and answered with an error (a refusal of its own: an entry + /// that doesn't balance, say). + Error, + /// It wasn't run, with the reason's code. + Refused(String), +} + +/// One access, as recorded (`seq`, `ts` and `prev` are the chain's). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Access { + /// Who. + pub principal: Principal, + /// With which program, if it said. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + /// The request's own id, if it had one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + /// `tool`, `resource`, or `connect` (a request refused before it + /// reached the books). + pub action: String, + /// The tool's name, the resource's address, or what was refused. + pub subject: String, + /// The SHA-256 of the arguments' canonical JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args_digest: Option, + /// What came of it. + pub result: Outcome, + /// The journal's head after the call's last write, when it wrote + /// (whatever it answered). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_after: Option, +} + +/// Mask any full TIN in a client's text, then cut it to +/// [`MAX_ASSERTED`] characters (INVARIANT 2). +pub fn asserted(text: &str) -> String { + // Masked first: a TIN cut in two at the limit would keep most of its + // digits (review of slice 4). + let masked = ledger_core::parties::redact_full_tins(text); + masked.chars().take(MAX_ASSERTED).collect() +} + +/// The digest of a call's arguments: the SHA-256 of their canonical JSON, +/// with every TIN masked first (INVARIANT 2), so that the digest can't be +/// matched against guesses of one. Without this a TIN whose last four the +/// books show is found among 10^5 guesses (review of slice 4). Masked: +/// any value under a key naming a TIN; any key or text holding one; any +/// number of eight digits or more (whatever its sign or fraction: a TIN +/// may start with a zero). A file's base64 bytes are hashed as they are: +/// base64 never spells a TIN in the clear, and scanning megabytes of it +/// costs more than it could protect. +pub fn digest(args: &Value) -> String { + sha256_hex(canonical_json(&masked(args, false)).as_bytes()) +} + +/// Keys whose values are a file's bytes, base64 (see [`digest`]). +const FILE_BYTES: [&str; 2] = ["content_base64", "w9_content_base64"]; + +/// `v` with every TIN masked (see [`digest`]); `bytes`: a file's base64. +fn masked(v: &Value, bytes: bool) -> Value { + const MASK: &str = "[TIN]"; + match v { + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| { + let k_lower = k.to_ascii_lowercase(); + let names_a_tin = k_lower == "tin" + || k_lower.ends_with("_tin") + || k_lower == "ssn" + || k_lower == "ein" + || k_lower == "itin"; + let v = if names_a_tin && !v.is_null() { + Value::from(MASK) + } else { + masked(v, FILE_BYTES.contains(&k_lower.as_str())) + }; + (ledger_core::parties::redact_full_tins(k).into_owned(), v) + }) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(|v| masked(v, false)).collect()), + Value::String(_) if bytes => v.clone(), + Value::String(s) => Value::from(ledger_core::parties::redact_full_tins(s).into_owned()), + Value::Number(n) if n.as_f64().is_none_or(|f| f.abs() >= 1e7) => Value::from(MASK), + other => other.clone(), + } +} + +/// A book's access log, open for recording. +#[derive(Debug)] +pub struct AccessLog { + log: Mutex, +} + +impl AccessLog { + /// Open the access log in a book's folder (made if missing), checking + /// it whole (INVARIANT 1). + pub fn open(book_dir: &Path) -> Result { + let (log, records) = ChainLog::open(&book_dir.join(ACCESS_FILE), ACCESS_GENESIS)?; + // Every record an access, as read would take it: a log that reads + // only as a chain isn't whole. + for (i, record) in records.into_iter().enumerate() { + as_recorded(i, record)?; + } + Ok(Self { + log: Mutex::new(log), + }) + } + + /// Record one access; Ok only once it is on disk (INVARIANTS 1, 3). + pub fn record(&self, access: &Access) -> Result { + let mut access = access.clone(); + access.principal.label = asserted(&access.principal.label); + if let Some(c) = &mut access.client { + c.name = asserted(&c.name); + c.version = asserted(&c.version); + } + access.request_id = access.request_id.as_deref().map(asserted); + access.subject = asserted(&access.subject); + let Value::Object(fields) = serde_json::to_value(&access).expect("a record serializes") + else { + unreachable!("a record is an object") + }; + let mut log = self.log.lock().unwrap_or_else(|e| e.into_inner()); + log.append(fields).map(|(_, head)| head) + } + + /// Where the log ends now. + pub fn head(&self) -> Head { + self.log + .lock() + .unwrap_or_else(|e| e.into_inner()) + .head() + .clone() + } +} + +/// One record as read back: the access and the chain's fields. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Recorded { + /// Its number. + pub seq: u64, + /// When (UTC, RFC 3339). + pub ts: String, + /// The access. + #[serde(flatten)] + pub access: Access, +} + +/// Read a book's access log whole, checking it (INVARIANT 1). An +/// unfinished last line (a record being written, or cut short by a +/// crash) is left out. +pub fn read(book_dir: &Path) -> Result<(Vec, Head), ChainError> { + let read = chain::read(&book_dir.join(ACCESS_FILE), ACCESS_GENESIS)?; + let mut out = Vec::with_capacity(read.records.len()); + for (i, record) in read.records.into_iter().enumerate() { + out.push(as_recorded(i, record)?); + } + Ok((out, read.head)) +} + +/// The `i`th record (from 0) read as an access. +fn as_recorded(i: usize, mut record: Map) -> Result { + record.remove("prev"); + record.remove("hash"); + serde_json::from_value(Value::Object(record)).map_err(|e| ChainError::Damaged { + line: i as u64 + 1, + why: format!("it isn't an access record ({e})"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn access(n: u64) -> Access { + Access { + principal: Principal { + id: "tok_abc".into(), + kind: "agent".into(), + label: "claude-code".into(), + }, + client: Some(Client { + name: "claude-code".into(), + version: "2.1".into(), + }), + request_id: Some(n.to_string()), + action: "tool".into(), + subject: "books.trial_balance".into(), + args_digest: Some(digest(&serde_json::json!({"as_of": "2026-10-19"}))), + result: Outcome::Ok, + head_after: None, + } + } + + #[test] + fn records_read_back_as_written() { + let dir = tempfile::tempdir().unwrap(); + let log = AccessLog::open(dir.path()).unwrap(); + let mut refused = access(2); + refused.result = Outcome::Refused("insufficient_scope".into()); + refused.principal = Principal::unknown(); + let mut posted = access(3); + posted.subject = "books.post_entry".into(); + posted.head_after = Some(Head { + seq: 41, + hash: "ab".repeat(32), + }); + let mut errored = access(4); + errored.result = Outcome::Error; + errored.client = None; + errored.request_id = None; + errored.args_digest = None; + let all = [access(1), refused, posted, errored]; + for (i, a) in all.iter().enumerate() { + assert_eq!(log.record(a).unwrap().seq, i as u64 + 1); + } + assert_eq!(log.head().seq, 4); + let (back, head) = read(dir.path()).unwrap(); + assert_eq!(head, log.head()); + assert_eq!(back.len(), 4); + for (i, r) in back.iter().enumerate() { + assert_eq!(r.seq, i as u64 + 1); + assert_eq!(r.access, all[i]); + } + // The wire form the plan names. + let text = std::fs::read_to_string(dir.path().join(ACCESS_FILE)).unwrap(); + let second: Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap(); + assert_eq!( + second["result"], + serde_json::json!({"refused": "insufficient_scope"}) + ); + let first: Value = serde_json::from_str(text.lines().next().unwrap()).unwrap(); + assert_eq!(first["result"], "ok"); + assert_eq!(first["principal"]["kind"], "agent"); + } + + #[test] + fn what_a_client_asserts_is_cut_and_masked() { + let dir = tempfile::tempdir().unwrap(); + let log = AccessLog::open(dir.path()).unwrap(); + let mut a = access(1); + a.client = Some(Client { + name: "x".repeat(500), + version: "123-45-6789".into(), + }); + a.request_id = Some("ssn 123-45-6789".into()); + a.principal.label = "y".repeat(100); + a.subject = "books.123-45-6789".into(); + log.record(&a).unwrap(); + let text = std::fs::read_to_string(dir.path().join(ACCESS_FILE)).unwrap(); + assert!(!text.contains("123-45-6789"), "{text}"); + assert!(!text.contains(&"x".repeat(MAX_ASSERTED + 1))); + let (back, _) = read(dir.path()).unwrap(); + let client = back[0].access.client.as_ref().unwrap(); + assert_eq!(client.name.chars().count(), MAX_ASSERTED); + assert_eq!(back[0].access.principal.label.len(), MAX_ASSERTED); + assert!(!ledger_core::parties::has_full_tin(&client.version)); + } + + #[test] + fn a_digest_gives_no_tin_away() { + // The same call with another TIN: the same digest, so no guess + // of the TIN can be checked against it. + for (a, b) in [ + ( + serde_json::json!({"party": 4, "tin": "123-45-6789", "tin_type": "ssn"}), + serde_json::json!({"party": 4, "tin": "987-81-4321", "tin_type": "ssn"}), + ), + ( + serde_json::json!({"party": 4, "tin": 123456789}), + serde_json::json!({"party": 4, "tin": 987814321}), + ), + ( + serde_json::json!({"w9": {"payee_tin": "12-3456789"}}), + serde_json::json!({"w9": {"payee_tin": "98-7814321"}}), + ), + ( + serde_json::json!({"note": "SSN 123-45-6789 on file"}), + serde_json::json!({"note": "SSN 987-81-4321 on file"}), + ), + ( + serde_json::json!({"memo": [123456789]}), + serde_json::json!({"memo": [987814321]}), + ), + // Round 2: as a key, as a float, negative, with a leading zero + // lost, or run into another digit. + ( + serde_json::json!({"structured": {"123-45-6789": "6100"}}), + serde_json::json!({"structured": {"987-81-4321": "6100"}}), + ), + ( + serde_json::json!({"x": 123456789.0}), + serde_json::json!({"x": 987814321.0}), + ), + ( + serde_json::json!({"x": -123456789}), + serde_json::json!({"x": -987814321}), + ), + ( + serde_json::json!({"x": 12345678}), + serde_json::json!({"x": 98765432}), + ), + ( + serde_json::json!({"x": 1123456789}), + serde_json::json!({"x": 1987814321}), + ), + ] { + assert_eq!(digest(&a), digest(&b), "{a}"); + } + // Everything else still counts. + assert_ne!( + digest(&serde_json::json!({"party": 4, "tin": "123-45-6789"})), + digest(&serde_json::json!({"party": 5, "tin": "123-45-6789"})) + ); + assert_ne!( + digest(&serde_json::json!({"amount": "12.34"})), + digest(&serde_json::json!({"amount": "12.35"})) + ); + assert_ne!( + digest(&serde_json::json!({"entry_id": 1234567})), + digest(&serde_json::json!({"entry_id": 1234568})) + ); + // A file's bytes count as they are. + assert_ne!( + digest(&serde_json::json!({"content_base64": "MTIzNDU2Nzg5"})), + digest(&serde_json::json!({"content_base64": "OTg3NjU0MzIx"})) + ); + } + + #[test] + fn a_tin_at_the_cut_is_masked_whole() { + // The TIN straddles the 64-character limit. + let text = format!("{}123-45-6789", "x".repeat(58)); + let kept = asserted(&text); + assert!(!kept.contains("123-45-67"), "{kept}"); + assert!(kept.chars().count() <= MAX_ASSERTED); + } + + #[test] + fn the_digest_is_of_canonical_arguments() { + let a = serde_json::json!({"b": 1, "a": [2, {"d": 3, "c": 4}]}); + let b: Value = serde_json::from_str(r#"{"a":[2,{"c":4,"d":3}],"b":1}"#).unwrap(); + assert_eq!(digest(&a), digest(&b)); + assert_ne!(digest(&a), digest(&serde_json::json!({"b": 2}))); + assert_eq!(digest(&a).len(), 64); + } + + #[test] + fn another_chains_records_dont_pass() { + let dir = tempfile::tempdir().unwrap(); + let (mut journal_like, _) = + ChainLog::open(&dir.path().join(ACCESS_FILE), "wanderbooks-principals-v1").unwrap(); + let Value::Object(fields) = serde_json::to_value(access(1)).unwrap() else { + unreachable!() + }; + journal_like.append(fields).unwrap(); + assert!(read(dir.path()).is_err()); + assert!(AccessLog::open(dir.path()).is_err()); + } + + #[test] + fn a_record_that_isnt_an_access_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let (mut log, _) = ChainLog::open(&dir.path().join(ACCESS_FILE), ACCESS_GENESIS).unwrap(); + let mut m = Map::new(); + m.insert("what".into(), Value::from("nothing")); + log.append(m).unwrap(); + assert!(matches!( + read(dir.path()).unwrap_err(), + ChainError::Damaged { line: 1, .. } + )); + // Nor is such a log opened (its book stays closed). + assert!(matches!( + AccessLog::open(dir.path()).unwrap_err(), + ChainError::Damaged { line: 1, .. } + )); + } + + #[test] + fn records_from_many_threads_all_land_in_one_chain() { + let dir = tempfile::tempdir().unwrap(); + let log = std::sync::Arc::new(AccessLog::open(dir.path()).unwrap()); + let threads: Vec<_> = (0..8) + .map(|t| { + let log = log.clone(); + std::thread::spawn(move || { + for n in 0..25 { + log.record(&access(t * 100 + n)).unwrap(); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let (back, head) = read(dir.path()).unwrap(); + assert_eq!(back.len(), 200); + assert_eq!(head.seq, 200); + let mut ids: Vec = back + .iter() + .map(|r| r.access.request_id.clone().unwrap()) + .collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 200); + } +} diff --git a/crates/ledger-store/src/auth.rs b/crates/ledger-store/src/auth.rs new file mode 100644 index 0000000..a7e1e08 --- /dev/null +++ b/crates/ledger-store/src/auth.rs @@ -0,0 +1,618 @@ +//! Who may reach the books through the server: the root's principals +//! (plan 3.3, 3.4), kept in `auth/principals.jsonl`. +//! +//! ```text +//! GOAL: an agent reaches a book through the server only with a token +//! minted for that book, and only for what its scopes allow; a +//! revoked token stops working at once. +//! SOURCE: plan 3.3 ("a static bearer wb_ + 32 random bytes, stored only +//! as a sha256, compared in constant time ... revocable"), 3.4 +//! (auth/principals.jsonl, hash-chained), 3.5. +//! INVARIANTS: +//! 1. A token's text is never written: the log holds its SHA-256. Its +//! text leaves create once, to the caller, and nothing else returns +//! it. +//! 2. The live tokens are exactly the log replayed: every create adds +//! one (and ends the one it replaces), every revoke ends one. The +//! log is a chain (chain.rs), so a changed record is found when it +//! is read; one this version can't read is refused whole. +//! 3. Per book, a name belongs to at most one live token. +//! 4. check compares the presented token's SHA-256 with every live +//! token's, in constant time, with no early exit, and answers the +//! token it matched. +//! 5. Nothing here names a path. +//! ``` + +use crate::chain::{ChainError, ChainLog}; +use crate::root::BookId; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::path::Path; + +/// The principals' log, in the root's `auth/`. +pub const PRINCIPALS_FILE: &str = "principals.jsonl"; +/// Its genesis text. +pub const PRINCIPALS_GENESIS: &str = "wanderbooks-principals-v1"; +/// How every token begins. +pub const TOKEN_PREFIX: &str = "wb_"; + +/// What a token may do (plan 3.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Scope { + /// Reads, reports, `start`, the handbook, receipts. + #[serde(rename = "books:read")] + Read, + /// Drafts, lines, attachments, notes: what changes nothing posted. + #[serde(rename = "books:write")] + Write, + /// Posting, closing, reconciling, disposing, running schedules, + /// committing settings. + #[serde(rename = "books:post")] + Post, + /// The access log (later: backups, invitations). + #[serde(rename = "books:admin")] + Admin, +} + +impl Scope { + /// Every scope, in order. + pub const ALL: [Scope; 4] = [Scope::Read, Scope::Write, Scope::Post, Scope::Admin]; + + /// Its name, `books:read` and so on. + pub fn as_str(self) -> &'static str { + match self { + Scope::Read => "books:read", + Scope::Write => "books:write", + Scope::Post => "books:post", + Scope::Admin => "books:admin", + } + } + + /// A scope by its name, with or without `books:`. + pub fn parse(text: &str) -> Option { + let short = text.strip_prefix("books:").unwrap_or(text); + Scope::ALL + .into_iter() + .find(|s| s.as_str().strip_prefix("books:") == Some(short)) + } +} + +impl std::fmt::Display for Scope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A live token, as the log records it (never its text: INVARIANT 1). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Token { + /// Its id, `tok_` and 12 hex digits. + pub id: String, + /// The book it opens. + pub book: String, + /// Its name, unique among the book's live tokens. + pub name: String, + /// What it may do. + pub scopes: BTreeSet, + /// When it was made (UTC, RFC 3339). + pub created: String, + /// The hex SHA-256 of its text. + #[serde(skip_serializing)] + pub sha256: String, +} + +/// A token's text, which is shown once (INVARIANT 1). Debug hides it. +pub struct Secret(String); + +impl Secret { + /// The text itself. + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Secret(…)") + } +} + +/// Why the principals can't do what was asked. Never names a path. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AuthError { + /// The log can't be read or written. + #[error("the record of agent tokens can't be used: {0}")] + Log(ChainError), + /// A record this version doesn't know (a newer wanderbooks wrote it). + #[error("the record of agent tokens was written by a newer wanderbooks (record {0})")] + Newer(u64), + /// A record that contradicts the ones before it. + #[error("the record of agent tokens is inconsistent at record {0}: {1}")] + Inconsistent(u64, String), + /// A name that can't name a token. + #[error( + "{0:?} can't name a token: use 1 to 32 lowercase letters, digits and hyphens, starting with a letter or digit" + )] + BadName(String), + /// The book already has a live token by that name. + #[error( + "the books {book} already have a token called {name:?}: replace it, or revoke it first" + )] + Exists { + /// The book. + book: String, + /// The name. + name: String, + }, + /// No live token by that name. + #[error("the books {book} have no token called {name:?}")] + NoSuch { + /// The book. + book: String, + /// The name. + name: String, + }, + /// A token with no scopes would open nothing. + #[error("a token needs at least one scope")] + NoScopes, + /// The system gave no randomness. + #[error("the system gave no randomness for a token")] + NoRandom, +} + +impl From for AuthError { + fn from(e: ChainError) -> Self { + AuthError::Log(e) + } +} + +/// Check a token's name: 1 to 32 lowercase letters, digits and hyphens, +/// starting with a letter or digit. +pub fn check_name(name: &str) -> Result<(), AuthError> { + let ok = (1..=32).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !name.starts_with('-'); + if ok { + Ok(()) + } else { + Err(AuthError::BadName(name.chars().take(64).collect())) + } +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +fn random_hex(n: usize) -> Result { + let mut buf = vec![0u8; n]; + getrandom::getrandom(&mut buf).map_err(|_| AuthError::NoRandom)?; + Ok(hex::encode(buf)) +} + +/// The root's principals: the log and the live tokens it replays to. +#[derive(Debug)] +pub struct Principals { + log: ChainLog, + live: Vec, + /// Every token id ever made, live or not: a new one is never one of + /// them (the log refuses an id made twice). + ids: BTreeSet, +} + +impl Principals { + /// Open the principals in an `auth/` folder (made empty if missing), + /// replaying the log (INVARIANT 2). + pub fn open(auth_dir: &Path) -> Result { + let (log, records) = ChainLog::open(&auth_dir.join(PRINCIPALS_FILE), PRINCIPALS_GENESIS)?; + let mut live: Vec = Vec::new(); + let mut seen = BTreeSet::new(); + for record in records { + let seq = record.get("seq").and_then(Value::as_u64).unwrap_or(0); + let bad = |why: &str| AuthError::Inconsistent(seq, why.to_string()); + let event = record.get("event").and_then(Value::as_str); + let text = |key: &str| -> Result { + record + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| bad(&format!("no {key}"))) + }; + match event { + Some("token.created") => { + let id = text("id")?; + if !seen.insert(id.clone()) { + return Err(bad("a token id made twice")); + } + let scopes: BTreeSet = record + .get("scopes") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .ok_or_else(|| bad("its scopes can't be read"))?; + if scopes.is_empty() { + return Err(bad("a token with no scopes")); + } + let token = Token { + id, + book: text("book")?, + name: text("name")?, + scopes, + created: text("ts")?, + sha256: text("sha256")?, + }; + if let Some(old) = record.get("replaces") { + let old = old.as_str().ok_or_else(|| bad("replaces no token"))?; + let at = live + .iter() + .position(|t| { + t.id == old && t.book == token.book && t.name == token.name + }) + .ok_or_else(|| bad("it replaces a token that isn't live"))?; + live.remove(at); + } + if live + .iter() + .any(|t| t.book == token.book && t.name == token.name) + { + return Err(bad("a name given twice")); + } + live.push(token); + } + Some("token.revoked") => { + let id = text("id")?; + let at = live + .iter() + .position(|t| t.id == id) + .ok_or_else(|| bad("it revokes a token that isn't live"))?; + live.remove(at); + } + _ => return Err(AuthError::Newer(seq)), + } + } + Ok(Self { + log, + live, + ids: seen, + }) + } + + /// The live tokens, oldest first. + pub fn tokens(&self) -> &[Token] { + &self.live + } + + /// Make a token for `book` (INVARIANTS 1, 3). With `replace`, a live + /// token of the same name ends in the same record; without it, one + /// is refused. + pub fn create( + &mut self, + book: &BookId, + name: &str, + scopes: BTreeSet, + replace: bool, + ) -> Result<(Token, Secret), AuthError> { + check_name(name)?; + if scopes.is_empty() { + return Err(AuthError::NoScopes); + } + let old = self + .live + .iter() + .position(|t| t.book == book.as_str() && t.name == name); + if old.is_some() && !replace { + return Err(AuthError::Exists { + book: book.to_string(), + name: name.to_string(), + }); + } + let text = format!("{TOKEN_PREFIX}{}", random_hex(32)?); + let id = loop { + let id = format!("tok_{}", random_hex(6)?); + if !self.ids.contains(&id) { + break id; + } + }; + let sha = hex::encode(sha256(text.as_bytes())); + let mut fields = Map::new(); + fields.insert("event".into(), Value::from("token.created")); + fields.insert("id".into(), Value::from(id.clone())); + fields.insert("book".into(), Value::from(book.as_str())); + fields.insert("name".into(), Value::from(name)); + fields.insert( + "scopes".into(), + serde_json::to_value(&scopes).expect("scopes serialize"), + ); + fields.insert("sha256".into(), Value::from(sha.clone())); + if let Some(at) = old { + fields.insert("replaces".into(), Value::from(self.live[at].id.clone())); + } + let (record, _) = self.log.append(fields)?; + if let Some(at) = old { + self.live.remove(at); + } + let token = Token { + id, + book: book.to_string(), + name: name.to_string(), + scopes, + created: record["ts"].as_str().unwrap_or_default().to_string(), + sha256: sha, + }; + self.ids.insert(token.id.clone()); + self.live.push(token.clone()); + Ok((token, Secret(text))) + } + + /// End `book`'s token called `name`. + pub fn revoke(&mut self, book: &BookId, name: &str) -> Result { + self.revoke_if(book, name, None) + } + + /// End `book`'s token called `name`, only if its id is `id` when one + /// is given (taking back a token just made, which another may have + /// replaced meanwhile). + pub fn revoke_if( + &mut self, + book: &BookId, + name: &str, + id: Option<&str>, + ) -> Result { + let at = self + .live + .iter() + .position(|t| { + t.book == book.as_str() && t.name == name && id.is_none_or(|id| t.id == id) + }) + .ok_or_else(|| AuthError::NoSuch { + book: book.to_string(), + name: name.chars().take(64).collect(), + })?; + let mut fields = Map::new(); + fields.insert("event".into(), Value::from("token.revoked")); + fields.insert("id".into(), Value::from(self.live[at].id.clone())); + self.log.append(fields)?; + Ok(self.live.remove(at)) + } + + /// The live token whose text this is, if any (INVARIANT 4). + pub fn check(&self, presented: &str) -> Option<&Token> { + use subtle::ConstantTimeEq; + let presented = sha256(presented.as_bytes()); + let mut found = None; + for token in &self.live { + let Ok(sha) = hex::decode(&token.sha256) else { + continue; + }; + if bool::from(sha.as_slice().ct_eq(&presented)) { + found = Some(token); + } + } + found + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn book(id: &str) -> BookId { + BookId::parse(id).unwrap() + } + + fn all() -> BTreeSet { + Scope::ALL.into_iter().collect() + } + + #[test] + fn scopes_read_by_name() { + for s in Scope::ALL { + assert_eq!(Scope::parse(s.as_str()), Some(s)); + assert_eq!(Scope::parse(&s.as_str()[6..]), Some(s)); + assert_eq!( + serde_json::to_value(s).unwrap(), + Value::from(s.as_str()), + "the log spells it as the plan does" + ); + } + assert_eq!(Scope::parse("books:view"), None); + assert_eq!(Scope::parse(""), None); + assert_eq!(Scope::parse("books:"), None); + } + + #[test] + fn names_are_checked() { + for good in ["agent", "claude-code", "a", "0x", &"a".repeat(32)] { + assert!(check_name(good).is_ok(), "{good}"); + } + for bad in ["", "-a", "Agent", "a b", "a/b", "a.b", &"a".repeat(33), "é"] { + assert!(check_name(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn a_token_is_checked_until_revoked_and_its_text_is_never_kept() { + let dir = tempfile::tempdir().unwrap(); + let mut p = Principals::open(dir.path()).unwrap(); + let (token, secret) = p.create(&book("acme"), "agent", all(), false).unwrap(); + let text = secret.expose().to_string(); + assert!(text.starts_with(TOKEN_PREFIX)); + assert_eq!(text.len(), 3 + 64); + assert!(token.id.starts_with("tok_")); + assert_eq!(p.check(&text), Some(&token)); + assert_eq!(p.check(&text[..text.len() - 1]), None); + assert_eq!(p.check(""), None); + assert_eq!(format!("{secret:?}"), "Secret(…)"); + let log = std::fs::read_to_string(dir.path().join(PRINCIPALS_FILE)).unwrap(); + assert!( + !log.contains(&text[3..]), + "the text itself is never written" + ); + assert!(log.contains(&token.sha256)); + // Another book's token is another token. + let (other, other_secret) = p.create(&book("beta"), "agent", all(), false).unwrap(); + assert_ne!(other.id, token.id); + assert_eq!(p.check(other_secret.expose()).unwrap().book, "beta"); + let revoked = p.revoke(&book("acme"), "agent").unwrap(); + assert_eq!(revoked.id, token.id); + assert_eq!(p.check(&text), None); + assert!(p.check(other_secret.expose()).is_some()); + assert!(matches!( + p.revoke(&book("acme"), "agent"), + Err(AuthError::NoSuch { .. }) + )); + // Taken back only if it is still the one named. + assert!(matches!( + p.revoke_if(&book("beta"), "agent", Some("tok_000000000000")), + Err(AuthError::NoSuch { .. }) + )); + assert!(p.revoke_if(&book("beta"), "agent", Some(&other.id)).is_ok()); + let (other, other_secret) = p.create(&book("beta"), "agent", all(), false).unwrap(); + // Reopened, the log replays to the same live tokens. + drop(p); + let p = Principals::open(dir.path()).unwrap(); + assert_eq!(p.tokens().len(), 1); + assert_eq!(p.check(&text), None); + assert_eq!(p.check(other_secret.expose()).unwrap().id, other.id); + } + + #[test] + fn a_name_is_taken_until_replaced() { + let dir = tempfile::tempdir().unwrap(); + let mut p = Principals::open(dir.path()).unwrap(); + let (first, first_secret) = p.create(&book("acme"), "agent", all(), false).unwrap(); + assert!(matches!( + p.create(&book("acme"), "agent", all(), false), + Err(AuthError::Exists { .. }) + )); + let read_only: BTreeSet = [Scope::Read].into(); + let (second, second_secret) = p + .create(&book("acme"), "agent", read_only.clone(), true) + .unwrap(); + assert_ne!(first.id, second.id); + assert_eq!(p.check(first_secret.expose()), None, "the old one ended"); + assert_eq!(p.check(second_secret.expose()).unwrap().scopes, read_only); + assert_eq!(p.tokens().len(), 1); + drop(p); + let p = Principals::open(dir.path()).unwrap(); + assert_eq!(p.tokens(), &[second]); + // A replace with nothing to replace is a create. + let mut p = p; + p.create(&book("acme"), "other", all(), true).unwrap(); + assert_eq!(p.tokens().len(), 2); + } + + #[test] + fn bad_requests_write_nothing() { + let dir = tempfile::tempdir().unwrap(); + let mut p = Principals::open(dir.path()).unwrap(); + assert!(matches!( + p.create(&book("acme"), "Bad Name", all(), false), + Err(AuthError::BadName(_)) + )); + assert_eq!( + p.create(&book("acme"), "agent", BTreeSet::new(), false) + .unwrap_err(), + AuthError::NoScopes + ); + let len = std::fs::metadata(dir.path().join(PRINCIPALS_FILE)) + .unwrap() + .len(); + assert_eq!(len, 0); + } + + /// Append a raw record to a fresh log, then open it. + fn open_with(records: &[Value]) -> Result { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(PRINCIPALS_FILE); + let (mut log, _) = ChainLog::open(&path, PRINCIPALS_GENESIS).unwrap(); + for r in records { + log.append(r.as_object().unwrap().clone()).unwrap(); + } + drop(log); + Principals::open(dir.path()) + } + + fn created(id: &str, name: &str) -> Value { + serde_json::json!({"event": "token.created", "id": id, "book": "acme", + "name": name, "scopes": ["books:read"], "sha256": "00"}) + } + + #[test] + fn a_log_that_contradicts_itself_is_refused() { + assert!(open_with(&[created("tok_1", "a")]).is_ok()); + let cases = [ + vec![created("tok_1", "a"), created("tok_1", "b")], + vec![created("tok_1", "a"), created("tok_2", "a")], + vec![serde_json::json!({"event": "token.revoked", "id": "tok_9"})], + vec![{ + let mut r = created("tok_1", "a"); + r["replaces"] = "tok_0".into(); + r + }], + vec![{ + let mut r = created("tok_1", "a"); + r["scopes"] = serde_json::json!([]); + r + }], + vec![{ + let mut r = created("tok_1", "a"); + r["scopes"] = serde_json::json!(["books:everything"]); + r + }], + vec![{ + let mut r = created("tok_1", "a"); + r.as_object_mut().unwrap().remove("book"); + r + }], + ]; + for records in cases { + let e = open_with(&records).unwrap_err(); + assert!(matches!(e, AuthError::Inconsistent(..)), "{records:?}: {e}"); + } + let e = open_with(&[serde_json::json!({"event": "token.renamed"})]).unwrap_err(); + assert_eq!(e, AuthError::Newer(1)); + } + + #[test] + fn a_changed_record_is_found() { + let dir = tempfile::tempdir().unwrap(); + let mut p = Principals::open(dir.path()).unwrap(); + p.create(&book("acme"), "agent", all(), false).unwrap(); + p.create(&book("acme"), "other", all(), false).unwrap(); + drop(p); + let path = dir.path().join(PRINCIPALS_FILE); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replacen("books:admin", "books:read", 1)).unwrap(); + assert!(matches!( + Principals::open(dir.path()).unwrap_err(), + AuthError::Log(ChainError::Damaged { line: 1, .. }) + )); + // The last record too. + let lines: Vec<&str> = text.lines().collect(); + std::fs::write( + &path, + format!( + "{} +{} +", + lines[0], + lines[1].replacen("books:admin", "books:read", 1) + ), + ) + .unwrap(); + assert!(matches!( + Principals::open(dir.path()).unwrap_err(), + AuthError::Log(ChainError::Damaged { line: 2, .. }) + )); + } + + #[test] + fn errors_name_no_path() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(PRINCIPALS_FILE), "x\n").unwrap(); + let e = Principals::open(dir.path()).unwrap_err().to_string(); + assert!(!e.contains(&dir.path().display().to_string()), "{e}"); + } +} diff --git a/crates/ledger-store/src/chain.rs b/crates/ledger-store/src/chain.rs new file mode 100644 index 0000000..5a43051 --- /dev/null +++ b/crates/ledger-store/src/chain.rs @@ -0,0 +1,508 @@ +//! Hash-chained logs beside the journal: a book's access log +//! (`books//access.jsonl`, plan I-9) and the root's principals +//! (`auth/principals.jsonl`, plan 3.3, 3.4). +//! +//! ```text +//! GOAL: a record, once acknowledged, is on disk and chained, and a later +//! change to it, or to any record before it, is found when the log +//! is read. (Whole records cut off the end, or the file deleted, +//! leave a shorter chain that still verifies: only a head kept +//! elsewhere finds that, plan I-10.) +//! SOURCE: plan 3.4 ("hash-chained access log, own genesis"), I-9 +//! ("logging fails closed"), I-10. +//! INVARIANTS: +//! 1. A log is lines of canonical JSON (journal::canonical_json), each +//! an object whose `seq` is 1, 2, 3 … with no gap, whose `prev` is +//! the SHA-256 of the line before (for the first, of the log's own +//! genesis text, so one log never passes for another), whose `ts` is +//! a string, and whose `hash` is the SHA-256 of the record without +//! it: the last record, which no later `prev` covers, is checked too +//! (against damage; a forger who rewrites a record's hash as well is +//! found only by a head kept elsewhere, plan I-10). +//! 2. append returns Ok only once the whole line is written and +//! synced. One that fails leaves the file cut back to where it was, +//! and the log takes no more appends: a log whose state is in doubt +//! is reopened (and so re-read) before it records anything else. +//! 3. read refuses a line that breaks 1, or isn't in canonical form +//! byte for byte, naming its seq. +//! 4. Only an unfinished last line (no newline: an append cut short by +//! a crash, never acknowledged) is set aside: open cuts it away, +//! read reports it. +//! ``` + +use crate::journal::{canonical_json, sha256_hex}; +use serde_json::{Map, Value}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Where a chain ends: its last record's seq (0 for an empty log) and the +/// SHA-256 the next record's `prev` must be. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Head { + /// The last record's seq. + pub seq: u64, + /// The last record's hash (the genesis hash for an empty log). + pub hash: String, +} + +/// Why a log can't be used. Never names a path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChainError { + /// A record breaks the chain (INVARIANT 3). + Damaged { + /// The line, counting from 1. + line: u64, + /// What is wrong with it. + why: String, + }, + /// The disk refused. + Io(std::io::ErrorKind), + /// An earlier append failed: the log must be reopened (INVARIANT 2). + Broken, +} + +impl std::fmt::Display for ChainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChainError::Damaged { line, why } => write!(f, "record {line} is damaged: {why}"), + ChainError::Io(kind) => write!(f, "the disk refused: {kind}"), + ChainError::Broken => f.write_str("an earlier record couldn't be written"), + } + } +} + +impl std::error::Error for ChainError {} + +impl From for ChainError { + fn from(e: std::io::Error) -> Self { + ChainError::Io(e.kind()) + } +} + +/// The `prev` of a log's first record: the SHA-256 of its genesis text. +pub fn genesis(text: &str) -> String { + sha256_hex(text.as_bytes()) +} + +/// A log read and checked. +#[derive(Debug, Clone)] +pub struct Read { + /// Every record, in order, `seq`, `ts`, `prev` and `hash` included. + pub records: Vec>, + /// Where the chain ends. + pub head: Head, + /// Whether an unfinished last line follows them (INVARIANT 4). + pub torn: bool, + /// How many bytes the whole records take: where a torn line begins. + pub good_len: u64, +} + +/// Read a log and check its chain (INVARIANTS 1, 3, 4). A log that +/// doesn't exist is empty. +pub fn read(path: &Path, genesis_text: &str) -> Result { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(e.into()), + }; + let mut records = Vec::new(); + let mut prev = genesis(genesis_text); + let mut at = 0usize; + let mut torn = false; + while at < bytes.len() { + let Some(end) = bytes[at..].iter().position(|&b| b == b'\n') else { + torn = true; + break; + }; + let line = &bytes[at..at + end]; + let n = records.len() as u64 + 1; + let damaged = |why: &str| ChainError::Damaged { + line: n, + why: why.to_string(), + }; + let value: Value = + serde_json::from_slice(line).map_err(|_| damaged("it isn't a JSON record"))?; + let canonical = canonical_json(&value); + if canonical.as_bytes() != line { + return Err(damaged("it was changed after it was written")); + } + let Value::Object(map) = value else { + return Err(damaged("it isn't a JSON record")); + }; + if map.get("seq").and_then(Value::as_u64) != Some(n) { + return Err(damaged("its number is out of order")); + } + if map.get("prev").and_then(Value::as_str) != Some(prev.as_str()) { + return Err(damaged("it doesn't follow the record before it")); + } + if !map.get("ts").is_some_and(Value::is_string) { + return Err(damaged("it has no time")); + } + let mut body = map.clone(); + let own = body.remove("hash"); + if own.as_ref().and_then(Value::as_str) + != Some(sha256_hex(canonical_json(&Value::Object(body)).as_bytes()).as_str()) + { + return Err(damaged("it was changed after it was written")); + } + prev = sha256_hex(canonical.as_bytes()); + records.push(map); + at += end + 1; + } + let head = Head { + seq: records.len() as u64, + hash: prev, + }; + Ok(Read { + records, + head, + torn, + good_len: at as u64, + }) +} + +/// A log open for appending. +pub struct ChainLog { + path: PathBuf, + file: File, + head: Head, + broken: bool, +} + +impl std::fmt::Debug for ChainLog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChainLog") + .field("head", &self.head) + .field("broken", &self.broken) + .finish_non_exhaustive() + } +} + +impl ChainLog { + /// Open a log (made, private, if it doesn't exist), checking it whole + /// and cutting away an unfinished last line (INVARIANT 4). Returns its + /// records too. + pub fn open( + path: &Path, + genesis_text: &str, + ) -> Result<(Self, Vec>), ChainError> { + let read = read(path, genesis_text)?; + let new = !path.exists(); + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path)?; + // One made by an older version, or loosened by hand: private again. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if file.metadata()?.permissions().mode() & 0o077 != 0 { + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + } + if read.torn { + file.set_len(read.good_len)?; + file.sync_all()?; + } + // A log just made is in its folder for good (its entry synced). + #[cfg(unix)] + if new && let Some(dir) = path.parent() { + File::open(dir)?.sync_all()?; + } + Ok(( + Self { + path: path.to_path_buf(), + file, + head: read.head, + broken: false, + }, + read.records, + )) + } + + /// Where the chain ends now. + pub fn head(&self) -> &Head { + &self.head + } + + /// The log's file. + pub fn path(&self) -> &Path { + &self.path + } + + /// Append one record: `fields` and the chain's `seq`, `ts`, `prev` + /// and `hash` (which `fields` must not hold). Returns the record as + /// written and the new head (INVARIANT 2). + pub fn append(&mut self, fields: Map) -> Result<(Value, Head), ChainError> { + if self.broken { + return Err(ChainError::Broken); + } + debug_assert!( + !["seq", "ts", "prev", "hash"] + .iter() + .any(|k| fields.contains_key(*k)), + "the chain sets seq, ts, prev and hash" + ); + let mut record = fields; + let seq = self.head.seq + 1; + record.insert("seq".into(), Value::from(seq)); + record.insert( + "ts".into(), + Value::from(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), + ); + record.insert("prev".into(), Value::from(self.head.hash.clone())); + let own = sha256_hex(canonical_json(&Value::Object(record.clone())).as_bytes()); + record.insert("hash".into(), Value::from(own)); + let record = Value::Object(record); + let mut line = canonical_json(&record); + let hash = sha256_hex(line.as_bytes()); + line.push('\n'); + let before = match self.file.metadata() { + Ok(m) => m.len(), + Err(e) => { + self.broken = true; + return Err(e.into()); + } + }; + let written = self + .file + .write_all(line.as_bytes()) + .and_then(|()| self.file.sync_data()); + if let Err(e) = written { + self.broken = true; + let _ = self.file.set_len(before); + let _ = self.file.sync_all(); + return Err(e.into()); + } + self.head = Head { seq, hash }; + Ok((record, self.head.clone())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const G: &str = "wanderbooks-test-v1"; + + fn fields(n: u64) -> Map { + let mut m = Map::new(); + m.insert("n".into(), Value::from(n)); + m.insert("word".into(), Value::from("café ✓")); + m + } + + #[test] + fn appended_records_read_back_chained() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + let (mut log, records) = ChainLog::open(&path, G).unwrap(); + assert!(records.is_empty()); + assert_eq!(log.head().seq, 0); + assert_eq!(log.head().hash, genesis(G)); + let mut heads = Vec::new(); + for n in 1..=3 { + let (record, head) = log.append(fields(n)).unwrap(); + assert_eq!(record["seq"], n); + assert_eq!(head.seq, n); + heads.push(head); + } + let back = read(&path, G).unwrap(); + assert_eq!(back.records.len(), 3); + assert_eq!(&back.head, heads.last().unwrap()); + assert!(!back.torn); + assert_eq!(back.records[1]["prev"], heads[0].hash.as_str()); + assert_eq!(back.records[2]["word"], "café ✓"); + // Reopened, it carries on from the same head. + drop(log); + let (mut log, records) = ChainLog::open(&path, G).unwrap(); + assert_eq!(records.len(), 3); + let (_, head) = log.append(fields(4)).unwrap(); + assert_eq!(head.seq, 4); + assert_eq!(read(&path, G).unwrap().head, head); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + // Loosened by hand: private again when next opened. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + drop(log); + drop(ChainLog::open(&path, G).unwrap()); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn another_logs_genesis_doesnt_pass() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + let (mut log, _) = ChainLog::open(&path, G).unwrap(); + log.append(fields(1)).unwrap(); + let e = read(&path, "wanderbooks-other-v1").unwrap_err(); + assert!(matches!(e, ChainError::Damaged { line: 1, .. }), "{e}"); + } + + fn rewrite(path: &Path, f: impl FnOnce(&mut Vec)) { + let text = std::fs::read_to_string(path).unwrap(); + let mut lines: Vec = text.lines().map(str::to_string).collect(); + f(&mut lines); + let mut out = lines.join("\n"); + out.push('\n'); + std::fs::write(path, out).unwrap(); + } + + fn three(dir: &Path) -> PathBuf { + let path = dir.join("log.jsonl"); + let (mut log, _) = ChainLog::open(&path, G).unwrap(); + for n in 1..=3 { + log.append(fields(n)).unwrap(); + } + path + } + + fn damaged_at(path: &Path) -> u64 { + match read(path, G).unwrap_err() { + ChainError::Damaged { line, .. } => line, + e => panic!("{e}"), + } + } + + #[test] + fn every_kind_of_change_is_found_at_its_line() { + let dir = tempfile::tempdir().unwrap(); + // A field changed: the record no longer matches its own hash. + let path = three(dir.path()); + rewrite(&path, |l| l[1] = l[1].replace("\"n\":2", "\"n\":9")); + assert_eq!(damaged_at(&path), 2); + // The last one too, which no later prev covers. + let path = three(&dir.path().join("a").tap_mkdir()); + rewrite(&path, |l| l[2] = l[2].replace("\"n\":3", "\"n\":8")); + assert_eq!(damaged_at(&path), 3); + // A record and its own hash rewritten together: the next line's + // prev catches it. + let path = three(&dir.path().join("h").tap_mkdir()); + rewrite(&path, |l| { + let mut v: Map = serde_json::from_str(&l[1]).unwrap(); + v.insert("n".into(), Value::from(9)); + v.remove("hash"); + let own = sha256_hex(canonical_json(&Value::Object(v.clone())).as_bytes()); + v.insert("hash".into(), Value::from(own)); + l[1] = canonical_json(&Value::Object(v)); + }); + assert_eq!(damaged_at(&path), 3); + // A record dropped. + let path = three(&dir.path().join("b").tap_mkdir()); + rewrite(&path, |l| { + l.remove(1); + }); + assert_eq!(damaged_at(&path), 2); + // Records swapped. + let path = three(&dir.path().join("c").tap_mkdir()); + rewrite(&path, |l| l.swap(0, 1)); + assert_eq!(damaged_at(&path), 1); + // Not canonical (a space added). + let path = three(&dir.path().join("d").tap_mkdir()); + rewrite(&path, |l| l[2] = l[2].replacen(':', ": ", 1)); + assert_eq!(damaged_at(&path), 3); + // Not JSON. + let path = three(&dir.path().join("e").tap_mkdir()); + rewrite(&path, |l| l[0] = "nonsense".into()); + assert_eq!(damaged_at(&path), 1); + // Not an object. + let path = three(&dir.path().join("f").tap_mkdir()); + rewrite(&path, |l| l[0] = "[1]".into()); + assert_eq!(damaged_at(&path), 1); + // A record without its time, or without its own hash. + for (sub, key) in [("g", "ts"), ("i", "hash")] { + let path = three(&dir.path().join(sub).tap_mkdir()); + rewrite(&path, |l| { + let mut v: Map = serde_json::from_str(&l[2]).unwrap(); + v.remove(key); + l[2] = canonical_json(&Value::Object(v)); + }); + assert_eq!(damaged_at(&path), 3, "a record without its {key}"); + } + } + + trait TapMkdir { + fn tap_mkdir(self) -> Self; + } + impl TapMkdir for PathBuf { + fn tap_mkdir(self) -> Self { + std::fs::create_dir_all(&self).unwrap(); + self + } + } + + #[test] + fn an_unfinished_last_line_is_cut_away_on_open() { + let dir = tempfile::tempdir().unwrap(); + let path = three(dir.path()); + let whole = std::fs::read(&path).unwrap(); + let mut torn = whole.clone(); + torn.extend_from_slice(br#"{"n":4,"pr"#); + std::fs::write(&path, &torn).unwrap(); + let r = read(&path, G).unwrap(); + assert!(r.torn); + assert_eq!(r.records.len(), 3); + assert_eq!(r.good_len, whole.len() as u64); + let (mut log, records) = ChainLog::open(&path, G).unwrap(); + assert_eq!(records.len(), 3); + assert_eq!(std::fs::read(&path).unwrap(), whole); + let (_, head) = log.append(fields(4)).unwrap(); + assert_eq!(head.seq, 4); + assert!(!read(&path, G).unwrap().torn); + } + + #[test] + fn a_damaged_log_isnt_opened() { + let dir = tempfile::tempdir().unwrap(); + let path = three(dir.path()); + rewrite(&path, |l| l[0] = l[0].replace("\"n\":1", "\"n\":7")); + assert!(matches!( + ChainLog::open(&path, G).unwrap_err(), + ChainError::Damaged { line: 1, .. } + )); + } + + #[test] + fn errors_name_no_path() { + for e in [ + ChainError::Damaged { + line: 3, + why: "x".into(), + }, + ChainError::Io(std::io::ErrorKind::PermissionDenied), + ChainError::Broken, + ] { + assert!(!e.to_string().contains('/'), "{e}"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn a_failed_append_leaves_the_log_whole_and_takes_no_more() { + // /dev/full refuses every write: an append there fails, and the + // log won't take another until reopened. + let file = OpenOptions::new().append(true).open("/dev/full").unwrap(); + let mut log = ChainLog { + path: PathBuf::from("/dev/full"), + file, + head: Head { + seq: 0, + hash: genesis(G), + }, + broken: false, + }; + assert!(matches!(log.append(fields(1)), Err(ChainError::Io(_)))); + assert_eq!(log.head().seq, 0, "the head didn't move"); + assert_eq!(log.append(fields(1)).unwrap_err(), ChainError::Broken); + } +} diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 8588465..41875ee 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -18,11 +18,15 @@ //! `last_applied_seq`. There is exactly one writer: `Store` is `!Sync` by //! construction (it owns a `rusqlite::Connection`). +pub mod access; +pub mod auth; pub mod blob; +pub mod chain; pub mod error; pub mod format; pub mod journal; pub mod meta; +pub mod passphrase; pub mod reconcilable; pub mod root; pub mod schema; diff --git a/crates/ledger-store/src/passphrase.rs b/crates/ledger-store/src/passphrase.rs new file mode 100644 index 0000000..4c7d3d6 --- /dev/null +++ b/crates/ledger-store/src/passphrase.rs @@ -0,0 +1,304 @@ +//! The owner passphrase (plan 3.3): the human factor that the server's +//! human pages and the viewer's consents ask for, kept as an Argon2id +//! hash in the root's `auth/owner.json`. +//! +//! ```text +//! GOAL: only someone who knows the passphrase gets past a check of it, +//! and its text is never kept. +//! SOURCE: plan 3.3 ("the owner passphrase (Argon2id, RFC 9106, m=64 MiB, +//! t=3, p=1)"), 3.9 (consents carry it), NIST SP 800-63B 3.1.1.2 +//! (a length floor, no composition rules, any characters). +//! INVARIANTS: +//! 1. The file holds an Argon2id PHC string (its own random salt) and +//! when it was set; never the passphrase. +//! 2. A passphrase is 12 to 1024 characters, any characters; nothing +//! else is asked of it. +//! 3. Changing one that is set needs the one in force; setting the +//! first needs none. The file is replaced whole (written aside, +//! synced, renamed), so a reader sees the old or the new, never part. +//! 4. A check uses the stored hash's own parameters, and refuses a hash +//! weaker than the plan's, of another Argon2 version, with a short +//! salt or output, or asking more than a check can afford (a record +//! changed by hand). +//! 5. Nothing here names a path or repeats a passphrase. +//! ``` + +use argon2::password_hash::phc::PasswordHash; +use argon2::password_hash::{PasswordHasher, PasswordVerifier}; +use argon2::{Algorithm, Argon2, Params, Version}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// The owner passphrase's file, in the root's `auth/`. +pub const OWNER_FILE: &str = "owner.json"; +/// The shortest passphrase taken (INVARIANT 2). +pub const MIN_CHARS: usize = 12; +/// The longest. +pub const MAX_CHARS: usize = 1024; +/// Argon2id's memory, KiB (plan 3.3: 64 MiB). +pub const M_COST: u32 = 64 * 1024; +/// Its passes (plan 3.3). +pub const T_COST: u32 = 3; +/// Its lanes (plan 3.3). +pub const P_COST: u32 = 1; +/// The most memory a stored hash may ask a check for, KiB (1 GiB). +const MAX_M_COST: u32 = 1024 * 1024; +/// The most passes. +const MAX_T_COST: u32 = 16; +/// The most lanes. +const MAX_P_COST: u32 = 8; +/// The shortest salt taken, in bytes. +const MIN_SALT: usize = 16; + +/// Why a passphrase can't be set or checked. Never names a path, never +/// repeats a passphrase. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PassError { + /// Too short or too long (INVARIANT 2). + #[error("a passphrase is {MIN_CHARS} to {MAX_CHARS} characters long")] + Length, + /// No passphrase is set yet. + #[error("no owner passphrase is set yet: set one with `wanderbooks passphrase`")] + NotSet, + /// Changing it needs the one in force, which wasn't given or didn't + /// match. + #[error("that isn't the owner passphrase in force")] + Wrong, + /// The record can't be read, or was changed by hand. + #[error("the owner passphrase's record can't be used: {0}")] + Record(String), + /// The disk refused. + #[error("the owner passphrase couldn't be kept: {0}")] + Io(std::io::ErrorKind), +} + +impl From for PassError { + fn from(e: std::io::Error) -> Self { + PassError::Io(e.kind()) + } +} + +#[derive(Serialize, Deserialize)] +struct Owner { + argon2id: String, + set: String, +} + +fn argon2() -> Argon2<'static> { + Argon2::new( + Algorithm::Argon2id, + Version::V0x13, + Params::new(M_COST, T_COST, P_COST, None).expect("the plan's parameters are valid"), + ) +} + +/// Check a passphrase's length (INVARIANT 2). +pub fn check_length(passphrase: &str) -> Result<(), PassError> { + if (MIN_CHARS..=MAX_CHARS).contains(&passphrase.chars().count()) { + Ok(()) + } else { + Err(PassError::Length) + } +} + +fn read(auth_dir: &Path) -> Result, PassError> { + match std::fs::read(auth_dir.join(OWNER_FILE)) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| PassError::Record("it isn't what wanderbooks writes".into())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Whether a passphrase is set. +pub fn is_set(auth_dir: &Path) -> Result { + Ok(read(auth_dir)?.is_some()) +} + +/// Whether `given` is the passphrase in force (INVARIANT 4). +pub fn verify(auth_dir: &Path, given: &str) -> Result { + let owner = read(auth_dir)?.ok_or(PassError::NotSet)?; + let hash = PasswordHash::new(&owner.argon2id) + .map_err(|_| PassError::Record("its hash can't be read".into()))?; + let params = + Params::try_from(&hash).map_err(|_| PassError::Record("its hash can't be read".into()))?; + // Nothing weaker than the plan's, and nothing a check couldn't afford + // (a record changed by hand to ask for terabytes): INVARIANT 4. + let version = hash.version.unwrap_or(0); + let salt = hash.salt.as_ref().map_or(0, |s| s.len()); + let out = hash.hash.as_ref().map_or(0, |h| h.len()); + if hash.algorithm.as_str() != "argon2id" + || version != 0x13 + || !(M_COST..=MAX_M_COST).contains(¶ms.m_cost()) + || !(T_COST..=MAX_T_COST).contains(¶ms.t_cost()) + || !(P_COST..=MAX_P_COST).contains(¶ms.p_cost()) + || salt < MIN_SALT + || out != 32 + { + return Err(PassError::Record("it was changed by hand".into())); + } + Ok(argon2().verify_password(given.as_bytes(), &hash).is_ok()) +} + +/// Set the passphrase (INVARIANTS 1, 3): the first one with `current` +/// None; a new one only with the one in force. +pub fn set(auth_dir: &Path, new: &str, current: Option<&str>) -> Result<(), PassError> { + check_length(new)?; + if is_set(auth_dir)? { + match current { + Some(c) if verify(auth_dir, c)? => {} + _ => return Err(PassError::Wrong), + } + } + let hash = argon2() + .hash_password(new.as_bytes()) + .map_err(|_| PassError::Record("it couldn't be hashed".into()))? + .to_string(); + let owner = Owner { + argon2id: hash, + set: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }; + let mut text = serde_json::to_string_pretty(&owner).expect("the record serializes"); + text.push('\n'); + replace(auth_dir, OWNER_FILE, text.as_bytes()) +} + +/// Replace a file in `dir` whole (INVARIANT 3): written aside, 0600, +/// synced, renamed over, the folder synced. +fn replace(dir: &Path, name: &str, bytes: &[u8]) -> Result<(), PassError> { + use std::io::Write; + let aside = dir.join(format!(".{name}.new")); + match std::fs::remove_file(&aside) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&aside)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + std::fs::rename(&aside, dir.join(name))?; + #[cfg(unix)] + std::fs::File::open(dir)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIRST: &str = "correct horse battery"; + const SECOND: &str = "staple ünïcode ✓ phrase"; + + #[test] + fn lengths_are_the_only_rule() { + assert_eq!(check_length("short"), Err(PassError::Length)); + assert_eq!(check_length(&"a".repeat(11)), Err(PassError::Length)); + assert!(check_length(&"a".repeat(12)).is_ok()); + assert!( + check_length("ééééééééééé✓").is_ok(), + "characters, not bytes" + ); + assert!(check_length(&"a".repeat(1024)).is_ok()); + assert_eq!(check_length(&"a".repeat(1025)), Err(PassError::Length)); + } + + #[test] + fn set_checked_and_changed_only_with_the_one_in_force() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_set(dir.path()).unwrap()); + assert_eq!(verify(dir.path(), FIRST), Err(PassError::NotSet)); + set(dir.path(), FIRST, None).unwrap(); + assert!(is_set(dir.path()).unwrap()); + assert!(verify(dir.path(), FIRST).unwrap()); + assert!(!verify(dir.path(), "correct horse batterY").unwrap()); + assert!(!verify(dir.path(), "").unwrap()); + // Changing needs the one in force. + assert_eq!(set(dir.path(), SECOND, None), Err(PassError::Wrong)); + assert_eq!( + set(dir.path(), SECOND, Some("not the passphrase")), + Err(PassError::Wrong) + ); + assert!(verify(dir.path(), FIRST).unwrap(), "unchanged"); + set(dir.path(), SECOND, Some(FIRST)).unwrap(); + assert!(verify(dir.path(), SECOND).unwrap()); + assert!(!verify(dir.path(), FIRST).unwrap()); + assert_eq!( + set(dir.path(), "short", Some(SECOND)), + Err(PassError::Length) + ); + // The file holds a hash, never the passphrase, private. + let text = std::fs::read_to_string(dir.path().join(OWNER_FILE)).unwrap(); + assert!(!text.contains("staple")); + assert!(text.contains("$argon2id$v=19$m=65536,t=3,p=1$"), "{text}"); + assert!(!dir.path().join(".owner.json.new").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(OWNER_FILE)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn a_weakened_or_mangled_record_is_refused() { + let dir = tempfile::tempdir().unwrap(); + set(dir.path(), FIRST, None).unwrap(); + let path = dir.path().join(OWNER_FILE); + let good = std::fs::read_to_string(&path).unwrap(); + for (from, to) in [ + ("m=65536", "m=8"), + ("m=65536", "m=2097152"), + ("t=3", "t=1"), + ("t=3", "t=99"), + ("p=1", "p=64"), + ("v=19", "v=16"), + ("$argon2id$", "$argon2i$"), + ("$argon2id$", "nonsense"), + ] { + std::fs::write(&path, good.replace(from, to)).unwrap(); + assert!( + matches!(verify(dir.path(), FIRST), Err(PassError::Record(_))), + "{to}" + ); + } + std::fs::write(&path, "not json").unwrap(); + assert!(matches!( + verify(dir.path(), FIRST), + Err(PassError::Record(_)) + )); + } + + #[test] + fn a_leftover_from_an_interrupted_change_doesnt_stop_the_next() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".owner.json.new"), "half").unwrap(); + set(dir.path(), FIRST, None).unwrap(); + assert!(verify(dir.path(), FIRST).unwrap()); + } + + #[test] + fn errors_name_no_path_and_repeat_no_passphrase() { + for e in [ + PassError::Length, + PassError::NotSet, + PassError::Wrong, + PassError::Record("x".into()), + PassError::Io(std::io::ErrorKind::PermissionDenied), + ] { + let text = e.to_string(); + assert!(!text.contains('/') && !text.contains(FIRST), "{text}"); + } + } +} diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 148d32f..17a82d2 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -56,6 +56,8 @@ pub const MARKER_FILE: &str = ".wanderbooks-root"; pub const BOOKS_DIR: &str = "books"; /// The sockets and runtime state (plan 3.9). pub const RUN_DIR: &str = "run"; +/// Who may reach the books: principals and the owner passphrase. +pub const AUTH_DIR: &str = "auth"; /// Old copies moved aside at adoption, never deleted (plan 5.5). pub const LEGACY_DIR: &str = "legacy"; /// A book's TIN vault folder, in its own folder. @@ -513,6 +515,14 @@ impl Root { &self.path } + /// The root's `auth/` folder (its principals and the owner passphrase), + /// made 0700 if missing and refused if it isn't private. + pub fn auth_dir(&self) -> Result { + self.private_subdir(AUTH_DIR, false) + .map(|(dir, _)| dir) + .map_err(|e| self.hide(e)) + } + /// The root's `run/` folder (its sockets and locks), made 0700 if /// missing and refused if it isn't private. pub fn run_dir(&self) -> Result { diff --git a/plan/served.md b/plan/served.md index 7c6b29d..b170ed8 100644 --- a/plan/served.md +++ b/plan/served.md @@ -165,7 +165,7 @@ Both protocol eras are served: `initialize` and `server/discover` are both built | `books:admin` | tokens, access log, backup run, consolidate, invite | owner's agent (local default), owner | | `books:view` | remote replica only (P9) | viewer client only, never agents | -No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has just presented a human factor, or inside the local GUI process after the human types the passphrase (3.6). +No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has just presented a human factor, or inside the local GUI process after the human types the passphrase (3.6). *Until P7 moves TINs to the human pages, the three tools that can still put a full TIN out (`vendor_1099` with `include_tins`, `export_cpa_package`, `get_w9`) need books:admin, and sealing one (`set_tin`, `set_w9`) needs books:post (P2 slice 4, review).* - **Local:** a static bearer `wb_` + 32 random bytes, stored only as a sha256, compared in constant time, kept in the OS keyring, minted by `wanderbooks connect`, and revocable with `wanderbooks token revoke`. Agent and server run as the same user, so the token identifies the client and keeps other local users and web pages out. It is not a wall against processes running as the human. - Claude Code: `wanderbooks connect claude-code --book ` (also the app's "Connect Claude Code") runs `claude mcp add --scope user wanderbooks- -- mcp --book `: stdio, through the bridge (D-23). The bridge reads the token from the keyring itself, so Claude's config holds no token and no helper. It offers a `permissions.allow mcp__wanderbooks-` line. @@ -191,6 +191,16 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has **Human factor for `/h/`:** passkey with user verification (webauthn-rs; localhost is a secure context), or the owner passphrase (Argon2id, RFC 9106, m=64 MiB, t=3, p=1). Locally the passphrase is the default. Cookies are `__Host-` and SameSite=Strict, with CSRF tokens and CSP `default-src 'self'`. URL-mode elicitation is used where the client declares it, and the server requires that the session's `sub` equal the token's `sub`. +**As built (P2 slice 4), and where it departs:** +- **Tokens** are `wb_` and 64 hex digits (32 random bytes), one book and one name each (`agent` unless named), kept only as a SHA-256 in `auth/principals.jsonl`: a chain of `token.created` (with the token it replaces) and `token.revoked` records, each with its own hash besides the chain's (so a change to the last record is found too), replayed at start; one that can't be read refuses every agent with why (the list of books still answers). Checked in constant time against every live token. Made, listed and revoked only through the admin socket: `wanderbooks token create --book ID [--name N] [--scopes read,write,post,admin] [--replace] | list | revoke | print`. A token made is kept in the keyring before anything is said of it, or revoked again; `print` is the only way its text is shown. `connect` (slice 8) will mint through the same path. +- **Kept in the OS keyring** (Secret Service on Linux, through keyring-core; macOS and Windows stores come with P11), under service `wanderbooks` and an account naming the root by a hash of its path, the book and the token's name. The bridge (`wanderbooks mcp --book ID [--token NAME]`) reads it before each new session and each request until it has one, and again when the door refuses it, so a token made or replaced mid-session is used from the next call (with a new session: a session belongs to the token that opened it); without one, the server's refusal says how to make one. Tests keep tokens in files under the root's `run/` (the `file_keyring` knob). +- **The door** (`/b/{id}/mcp`): no token or an unknown one, 401 with `WWW-Authenticate: Bearer` (the scheme in any case); another book's, 403; a session is bound to its token when opened and used only with it (403), forgotten when it ends, is found gone (an idle one, when next used), or its token is revoked or replaced; an id the server never opened binds nothing; past 100,000 bindings an initialize is refused, never a binding dropped. Each refusal at the door is recorded in that book's access log (action `connect`). +- **Scopes** (`ledger-mcp/src/scopes.rs`): a table of every tool. Read-only tools need books:read; a tool with `confirmed` needs books:write to preview and books:post to act; tools that post at once, settings commits, voids, renumbering, corrections and role changes need books:post, and so do the controls on posting (setting an approval rule, retiring a convention, approving an entry) and sealing a TIN (`set_tin`, `set_w9`, which can replace one); `books.access_log` and the tools that can still put a full TIN out (`vendor_1099`, `export_cpa_package`, `get_w9`) need books:admin. A token is offered only the tools it can call (the list is then cached per caller, `cacheScope: private`); a call beyond its scopes is refused, unrun, naming the scope. Reading a resource needs books:read. A local token gets all four unless told otherwise. +- **The access log** (`books//access.jsonl`, own genesis) records every tool call and resource read, refusals included: who (token id, name), the client as it named itself (cut to 64 characters, any full TIN masked), the request id, the tool or resource, a SHA-256 of the arguments' canonical JSON with what may be a TIN masked first (a value under a key naming one, any key or text holding one in a shape the TIN scanner knows, any number of eight digits or more: an unsalted digest of a TIN whose last four the books show is broken by 10^5 guesses; a file's base64 is hashed as it is, never spelling a TIN in the clear), what came of it (`ok`, `error`, or `refused` with a code), and the journal's head after the call's own last write, if it wrote, whatever it answered (taken under the store's lock as the call lets go). A call's record is written on its own blocking thread while it still counts as running (one that panics part way included, with what it wrote), so a close waits for it too, and before the answer goes; one that can't be written stops the book (no more requests until a restart) and the answer says the call ran (a call refused as not run, by a close or a stopped server, keeps its "not run" code). A tool no table names is refused unrun. Known gap: a crash of the process between a call's journal write and its record leaves the journal ahead of the log for that call. A damaged log, or one holding a record that isn't an access, keeps its book closed, with why. `books.access_log` (admin) reads it newest first, filtered by token, action or outcome, paged with `before`; over stdio (no log) the tool isn't offered. +- **The owner passphrase** is an Argon2id hash (m=64 MiB, t=3, p=1, its own salt) in `auth/owner.json`, replaced whole; 12 to 1024 characters, no other rule (NIST SP 800-63B); a check refuses a hash weaker than that (changed by hand). `setup` at a terminal asks for it (twice); elsewhere it says to run `wanderbooks passphrase`, which works only at a terminal, unechoed, and asks for the one in force before a change. (Advisory, like everything local: a process running as the human can drive a pseudo-terminal and set the first one.) A stored hash of another Argon2 version, with a short salt or output, or asking a check for more than 1 GiB, 16 passes or 8 lanes is refused as changed by hand. The command line wipes token texts and passphrases from memory once used (the server and the bridge, which hold tokens for their lifetime, don't). `doctor` says whether it is set and lists the tokens. Its uses (`/h/` pages, consents) come with the slices that build them. +- **Known, product-wide (for P7):** the TIN scanner (`has_full_tin`) takes hyphens and spaces as separators, not commas or parentheses, so "123,45,6789" passes it in party fields and the access-log digest alike. +- **Departures:** rate limits (tower-governor) wait for P9: on a socket only this account can reach, nothing could be limited that couldn't read the files instead. Principals are per book, not lists of books. `initialize` (the connect text, composed from the book's settings) is given to any valid token and isn't recorded. Changing the token record holds its lock across one fsync, briefly delaying token checks. + ### 3.4 Data layout (server-owned data root, 0700) ``` @@ -1097,7 +1107,7 @@ Run from clients that hold only a URL and a credential: Claude Code started in ` - Agent context on receipts never exceeded 100K tokens. - The owner's sign-off is journaled as `decision.recorded`. -### P11: One app for Windows, Mac and Linux (D-19, D-22, D-24) +### P11: One app for Windows, Mac and Linux (D-19, D-22, D-24, D-27) **Scope:** - **Tooling: cargo-packager (D-25).** Its `cargo packager` builds `Wanderbooks.app` and the `.dmg`, the NSIS setup `.exe` (in its default `CurrentUser` mode: no admin, metadata under `HKCU`), the `.deb`, the AppImage, and the pacman `PKGBUILD` the AUR package starts from; `cargo-packager-updater` updates the formats that update themselves (`app`, `nsis`, `appimage`; `wix` too, if an `.msi` is ever made). It has no `.rpm`, so cargo-generate-rpm makes that. Both tools are pinned in `mise.toml`, like every tool. Checked 2026-10-18 against its source (packager 0.11.8, updater 0.2.3, both pre-1.0): the updater verifies each bundle's minisign (Ed25519) signature against one public key and by default takes only a newer semver than the running one. It has no expiry, no second key and no key rotation, so our signed release manifest (below) is checked first, and the bundle's own minisign signature after it; the primary release key is the minisign key. P11's first step is a short trial of all six formats on clean machines; anything it can't meet goes in the change log before we build around it. @@ -1113,12 +1123,20 @@ Run from clients that hold only a URL and a credential: Claude Code started in ` - **Release keys.** Two Ed25519 keys, a primary on a hardware token held by the owner and a backup kept offline; CI builds and code-signs but holds neither, so it cannot sign a manifest. Rotating or revoking a key is a statement signed by the other. Each manifest carries a version that only increases and an expiry: from the feed, an older version than the one installed is refused (no rollback attack), and so is an expired manifest (no frozen feed). Going back to the kept previous app is a local action by the owner, not a feed update, and stays allowed. - **Package-managed installs** (AUR, apt, dnf) are updated by the package manager, with its own signatures (our repository keys) and `sudo` as usual on Linux; our updater stays out, and `wanderbooks update` refuses there. The host notices the new executable and restarts when idle (3.9); the format gate still protects the books. - **Connecting agents from the app:** the menu offers "Connect Claude Code" and "Connect Codex" (P2's `connect`, with the absolute path of the installed executable, which updates keep stable), so no one edits a `PATH`. +- **The agent dock and books homes (D-27; owner, 2026-10-19: "a one stop shop … it boots up, you get the chat interface").** The app runs the agent itself, in a terminal docked inside its window, so a person who installs Wanderbooks needs nothing else open: + - **The CLIs are found or installed, never bundled.** The app looks for an installed `claude` and `codex` and offers the missing one through its vendor's official installer. Bundling is ruled out: Claude Code isn't redistributable, both release far faster than we do, and a second copy means a second login and a second set of permission rules. + - **The dock** is a full-width panel along the bottom of the main window (not the sidebar), resizable and collapsible, so the reports stay in view while the agent works: posts appear within a second and `books.show` moves the view, which is the point. Its header picks the agent: Claude Code by default, Codex if installed, remembered with the viewer's preferences. The terminal is `alacritty_terminal` rendered in egui (`egui_term`, or our own view over it), on a pseudo-terminal: Unix PTYs on Linux and macOS, ConPTY on Windows 10 1809 and later. `/login`, slash commands and the agents' own permission prompts work as in any terminal; we build no chat UI of our own (a native one over the Agent SDK or `codex app-server` stays a later option). + - **The agent is started by the viewer's helper** (3.9), not as the viewer's child: the viewer stays read-only and confined (3.6) while the agent runs unconfined, as the human, like one started in a terminal. + - **Each book has a books home**, the agent's working folder: a visible, ordinary folder named for the company (`~/Documents/Wanderbooks//`; the user's Documents folder on Windows), never the data root and nothing inside it. It holds only what makes an agent started there work with that book: an `AGENTS.md`/`CLAUDE.md` pointer ("call `books.start`"), the agent's project-level MCP configuration for that book (`wanderbooks mcp --book `), and `.claude/settings.json` allowing the wanderbooks tools so the daily flow doesn't prompt. No books data, no keys, no token. The agents' "trust this folder?" question is then about a harmless folder, and is answered by the person, once; the app never edits Claude's or Codex's own settings to pre-trust it (whether `wanderbooks connect codex`, which already writes Codex's configuration, may mark the books home trusted is asked of the owner when it is built). The books home is a convenience: agents started anywhere else keep working (I-11). + - **The first company's project folder becomes its books home.** After the P2 cutover clears its crumbs (10.3), `~/Documents/the first company-Books` holds exactly the pointer and the agent configuration: the first books home, and the template `New books…` makes the others from. + - **Windows.** The pseudo-terminal is ConPTY (it rewrites some escape sequences and has resize quirks; neither stops a terminal app). Each agent's own Windows support is checked in the first-step trial on a clean machine: Claude Code's need for Git for Windows, Codex's native support (historically WSL-first). An agent that isn't good enough there isn't offered in the dock on Windows, and the app still works as a viewer with agents started elsewhere. + - Open questions for the trial: whether Codex reads a per-folder MCP configuration (historically only the global `~/.codex/config.toml`; if not, `connect codex` writes one named server per book there); the exact layout of each agent's project files at that time. - Data root in each system's usual app-data folder (Application Support, `%LOCALAPPDATA%` (never the roaming profile), `~/.local/share`), as in 3.4 and 3.9. - Release builds and the full test suite on all three systems in CI; package builds for Arch, Debian and Fedora in their own containers. - The viewer menu of P2 (back up, export, restore, new books, close the books, connect an agent) is the app's whole surface beyond the viewer; there is no separate launcher app. - **Costs (company expenses, recorded with their receipts):** the Apple Developer Program (the company's enrollment, attachments 76 and 77); Windows code signing (Azure Artifact Signing, about $10 a month where eligible, or an OV certificate, about $150 to $300 a year). -**Tests:** `install_needs_no_admin` (dmg, Windows installer, AppImage, clean machines); `translocated_app_refuses_to_connect`; `manifest_rollback_and_expiry_refused`; `key_rotation_statement_verified`; `package_upgrade_restarts_host`; `update_is_atomic`; `failed_update_rolls_back`; `unsigned_update_refused` (bad release-key signature, bad code signature, a file whose sha256 differs from the manifest: each refused, nothing changed); `update_closes_books_cleanly` (during a burst of posts; head verifies after); `update_waits_for_long_jobs`; `format_change_update_backs_up_and_asks` (declining leaves the books untouched and openable by the old version); `bridge_survives_update`; `linux_packages_install_and_upgrade` (AUR, apt and dnf repositories in clean containers; AppImage self-update); `server_runs_with_viewer_closed`; P2's restore tests on all three systems. +**Tests:** `install_needs_no_admin` (dmg, Windows installer, AppImage, clean machines); `translocated_app_refuses_to_connect`; `manifest_rollback_and_expiry_refused`; `key_rotation_statement_verified`; `package_upgrade_restarts_host`; `update_is_atomic`; `failed_update_rolls_back`; `unsigned_update_refused` (bad release-key signature, bad code signature, a file whose sha256 differs from the manifest: each refused, nothing changed); `update_closes_books_cleanly` (during a burst of posts; head verifies after); `update_waits_for_long_jobs`; `format_change_update_backs_up_and_asks` (declining leaves the books untouched and openable by the old version); `bridge_survives_update`; `linux_packages_install_and_upgrade` (AUR, apt and dnf repositories in clean containers; AppImage self-update); `server_runs_with_viewer_closed`; `dock_runs_the_chosen_agent` (on each system: the dock starts Claude Code, and Codex where offered, in the book's books home, through the helper; the viewer stays confined); `books_home_holds_no_books_data` (only the pointer and the agents' configuration; no path into the data root); `agent_missing_offers_the_installer`; P2's restore tests on all three systems. **Accept:** - On a clean Windows and Mac machine, and on Linux with the AppImage: install, open, onboard and post a first entry with no terminal and no admin password. On Arch, Debian and Fedora: the same after the package manager's usual install. @@ -1199,6 +1217,9 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-23 | When the books are open (owner, 2026-10-18: "Also when an agent starts") | Amends D-21. The books are open while anything that opened them runs: the app, an agent session (its bridge opens them, windowless, for the session), a terminal running `wanderbooks serve`, or a CLI command. Each holds a lease on the host's lease-only socket, so a crashed holder lets go by itself; local agents speak to the server over a peer-checked local socket, never TCP (3.9). The first opener starts the one host; later ones attach; when the last lease ends the books close 10 s later, cleanly. "Close the books" ends every lease at once. Still no background service: nothing starts at login and nothing runs after the last holder. An agent session started with the books closed needs no reconnect (P2). | | D-24 | Installers and updates (owner, 2026-10-18: "A dmg for macOS, msi or whatever for windows, and packaged for Linux distributions. Win and Mac with updaters.") | macOS: a signed, notarized `.dmg`; the app updates itself. Windows: a signed per-user installer with no admin prompt (`.msi` only if a managed install needs one); the app updates itself. Linux: AUR, `.deb` with our apt repository, `.rpm` with our dnf repository (all updated by the package manager), and a self-updating AppImage. Every self-update is verified against our own release keys and the system's code signing; Linux packages are verified and updated by their package managers. An update restarts the books cleanly. A newer books format is the server's gate whatever the install: read-only until the owner consents, after a backup with the keys (3.9). "No admin password" holds for the dmg, the Windows installer and the AppImage; Linux packages use `sudo` as usual. Details and tooling in P11. | | D-25 | Packaging tool (owner, 2026-10-18: "Cargo packager looks good. Would like to use that.") | **cargo-packager** and its updater (crabnebula-dev, MIT/Apache-2.0) for the `.app`/`.dmg`, the per-user NSIS installer, `.deb`, AppImage and the AUR's `PKGBUILD`; cargo-generate-rpm for `.rpm`, which it lacks. Our release manifest (two keys, rotation, a version that only increases, an expiry) wraps its single-key minisign check (P11). | +| D-26 | Where releases are hosted (owner, 2026-10-19: "I'm assuming the GH releases page will do the hosting?") | **GitHub Releases** on the public mirror (`swaits/wanderbooks`): each release carries the `.dmg`, the setup `.exe`, the AppImage, the `.deb` and `.rpm`, and the signed release manifest; the self-updaters read the manifest at the fixed `releases/latest/download/` address, so no server of ours is needed. CI builds and code-signs on release tags on GitHub's macOS, Windows and Linux runners and holds no release key (P11). The apt and dnf repositories' signed indexes are served from GitHub Pages on the same repository; the AUR package points at the Release assets. | +| D-27 | The agent dock and books homes (owner, 2026-10-19: bundle the agents "then it'd be a one stop shop … a chat window maybe full span across the bottom"; "Yeah I like the idea of the embedded alacritty launching claude/codex … maybe MVP defaults to claude"; "you'd want like a 'books home' dir somewhere that makes sense so it has a place to work"; "add it to the plan!") | The app docks a terminal (alacritty_terminal; ConPTY on Windows) along the bottom of its window and runs Claude Code (the default) or Codex in it, found or installed through the vendors' installers, never bundled; started by the viewer's helper, in the book's **books home**: a visible folder per book holding only the agent's pointer and configuration for that book, never the data root. Trust in that folder is the person's to give. **Never required** (owner, 2026-10-19: "we wouldn't want to prevent people from using their OWN AI agent. They should never HAVE to use our built in tty+claude session! Ideally they could even use the claude.ai browser version to connect to it. And of course, eventually we'd host the entire MCP (and gui!) remotely ... use any agent they prefer to remote connect into that.. and they'd view the GUI in a browser."): the books stay fully usable from any agent the person runs themselves, locally or (P9) remotely, claude.ai in a browser included; the dock's one extra is pasting the app's suggested instructions straight into its session. A hosted MCP and a browser viewer are the later service (section 11). P11 (the agent dock and books homes). | +| D-28 | Who does what, locally (owner, 2026-10-19: "Just have the agent ask for them and the user replies. The interface is through claude/codex and that's that. Don't fucking stray from it. TIN, W-9 is NOT critical or super secret PII." and "for downloading the CPA package or other assets, I don't mind if that can happen through the GUI app ... the GUI is for READING the data. The claude/codex is for MUTATAING (and reading) the data!!!") | **Two interfaces, no third.** Every change to the books goes through the agent (Claude Code or Codex) over the MCP; the agent asks the human for what it needs, TINs and W-9s included, and seals them in the vault (`books.set_tin`, `books.set_w9`): no human web pages, terminal commands or open ports for that locally. Reading and downloading (the CPA package and other exports, receipts, documents) happen in the viewer, which still never writes the books. This supersedes, for local books, the `/h/` pages of 3.3, 4.7 and P7's TIN entry: I-7's "the agent never holds a full TIN" becomes "a TIN passes through the agent only on its way into the vault; no result shows one". `/h/` pages remain for self-hosted books reached remotely (P9). | ### 10.2 Deferred @@ -1217,6 +1238,17 @@ These are the only open owner questions. Ask them just before the move, one at a **Q-3. After the move, should the old copies of the books leave the project folder?** The project folder still holds the old journal and receipts (`books-data/`), the old sealed tax ID numbers (`vault/`), and the folder's git history, which contains the journal and receipts too. **Recommended: move them into the private folder's legacy area.** That is what "hidden away" means; otherwise an agent working in the project folder can still read the old copies. Nothing is deleted. +**Crumbs to clear at the cutover** (owner, 2026-10-19: "migrate the first company books into the new scheme, data directory, clear out the crumbs"). After the adoption verifies, nothing of the old arrangement may be left where an agent or a later setup trips on it; every item is moved per Q-3 (into the root's `legacy/`, nothing deleted) or removed if it holds no books data: +- `books-data/` (journal, blobs, database, lock, `gui.log`) and `vault/`, and the project folder's git history holding them (Q-3); +- the v1 vault key outside the books, `~/.config/wanderbooks/vault.key` (adoption copies it to the book's `keys/legacy.key`; the old one goes to `legacy/` with the vault); +- `wanderbooks.toml` (its settings are in the books since P1; only `[viewer] open_at_start` remains, which the app's own preferences replace); +- the scaffold: `justfile`, `scripts/`, `.claude/settings.json`'s SessionStart hook, `.codex/hooks.json`; `AGENTS.md`/`CLAUDE.md` become the 3-line pointer; +- the agents' old registrations: Claude Code's stdio `wanderbooks` server from `just mcp-add` (replaced by `wanderbooks connect`), and Codex's; +- old binaries: `ledger-mcp`/`ledger-gui` from `just build` or `cargo install` (the one `wanderbooks` executable replaces them); +- the viewer's old preferences file, eframe's `app.ron` in `$XDG_DATA_HOME/wanderbooks` (the data root's default place): the viewer moves it on its first run (ledger-gui prefs.rs), and `setup` must not refuse the default place for holding only that file (slice 8 handles it: moved to the viewer's own folder first); +- the agent's own notes that name the old paths (its memory), rewritten to the new arrangement. +The cutover's check adds: the project folder holds none of the above, and `wanderbooks doctor` is clean. + *For the engineer:* Q-1 was D-3, Q-2 was D-4 and Q-3 was D-2. Q-1's technical side is settled: `archive.git` is seeded from the existing git history, filtered to the journal and blob paths, includes the vault ciphertext, and is age-encrypted as a whole on push (3.4, 5.5). ---