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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:20 -06:00
1 parent a298d154b6
commit d1f3ad891a
28 files changed
+5504 -116

No files matched your search

+183
View File
@@ -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<RoleServer>) -> Option<Caller> {
context
.extensions
.get::<axum::http::request::Parts>()
.and_then(|parts| parts.extensions.get::<Caller>())
.cloned()
}
/// The client program, as it named itself at `initialize`.
fn client(context: &RequestContext<RoleServer>) -> Option<Client> {
context.peer.peer_info().map(|info| Client {
name: info.client_info.name.clone(),
version: info.client_info.version.clone(),
})
}
fn request_id(context: &RequestContext<RoleServer>) -> 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<AccessLog>,
caller: Option<Caller>,
client: Option<Client>,
request_id: String,
action: &'static str,
subject: String,
args_digest: Option<String>,
}
/// 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<AccessLog>,
context: &RequestContext<RoleServer>,
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<String, serde_json::Value>>,
) -> Option<String> {
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<String>) {
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<Head>) -> 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<Head>,
) -> 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(())
}
}
}
}
+162 -10
View File
@@ -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<String>,
/// 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<String>,
},
}
/// 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<String>,
/// 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<TokenInfo>),
/// 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::<Response>(&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::<Envelope>(
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"));
}
}
+2
View File
@@ -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;
+399 -35
View File
@@ -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<SharedStore>,
/// Its access log, where requests refused at the door are
/// recorded too (access.rs).
access: Arc<AccessLog>,
},
/// 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<Result<Principals, String>>,
/// Which token opened each MCP session: a session is used only with
/// the token that opened it (INVARIANT 8).
session_tokens: Mutex<BTreeMap<String, String>>,
}
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, ledger_store::auth::AuthError>,
) -> 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<Principals, String> {
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<Token, (&'static str, String)> {
// 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::<String>()
));
}
}
}
let mut replaced: Option<String> = 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<Arc<Books>>,
axum::extract::Path(book): axum::extract::Path<String>,
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::<String>()
),
)
.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::<String>()
),
)
.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::<serde_json::Value>(&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);
+359
View File
@@ -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<String, serde_json::Value>>,
) -> Option<Scope> {
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<Scope>) -> 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<String> = 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::<std::collections::BTreeSet<_>>();
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)));
}
}
+324 -20
View File
@@ -71,6 +71,10 @@ pub struct BooksServer {
settings: Arc<Mutex<Live>>,
import_roots: Arc<ImportRoots>,
layout: Arc<Layout>,
/// 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<Arc<ledger_store::access::AccessLog>>,
}
/// 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<Box<dyn std::future::Future<Output = T> + 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<ledger_store::access::AccessLog>) -> 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> {
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<Option<(u64, String)>> = 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<StatementLineOut> {
// 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<RoleServer>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
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<rmcp::model::CallToolResponse, rmcp::ErrorData>| 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<serde_json::Map<String, serde_json::Value>>| 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::<String>()
),
)),
(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::<Vec<_>>()
.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<rmcp::model::PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
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<RoleServer>,
context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
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<rmcp::model::CallToolResponse, rmcp::ErrorData>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
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<ReadResourceResponse, rmcp::ErrorData> {
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());
+114
View File
@@ -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<usize>,
/// Only records before this one (its seq), to page back.
#[serde(default)]
pub before: Option<u64>,
/// Only calls by the token of this name.
#[serde(default)]
pub token: Option<String>,
/// 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<String>,
/// Only this outcome: ok, error or refused.
#[serde(default)]
pub outcome: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AccessLogOut {
/// The records, newest first.
pub records: Vec<serde_json::Value>,
/// 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<AccessLogParams>,
) -> Result<Json<AccessLogOut>, 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<serde_json::Value> = 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}),
}))
}
}