Files
wanderbooks/crates/ledger-mcp/src/scopes.rs
T
swaitsandClaude Opus 5.5 d1f3ad891a 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
2026-10-20 11:14:20 -06:00

360 lines
13 KiB
Rust

//! 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)));
}
}