blob: 84c09f3c8fc9c51dcbe1cdafacb47d71e559b0d1 [file]
//! The grants file: what one owner may read, at what label, and where it may go.
//!
//! On a laptop there is one principal, the owner, and a file they edit. The file is
//! compiled into a [`ReadFilter`] that the index applies inside every query, and into the
//! ceilings that every outbound call is checked against. An institution replaces this
//! crate's input with its identity provider and policy engine; the filter and the
//! ceilings it produces are the same types.
//!
//! The file:
//!
//! ```toml
//! owner = "human:local:striker"
//! handling = "restricted" # the highest classification the owner reads
//!
//! [domains.dom-striker] # one table per domain the owner holds
//! default_classification = "internal"
//!
//! [destinations.github] # one table per place the agent may send to
//! ceiling = "public"
//! [destinations.notes]
//! ceiling = "restricted"
//! ```
//!
//! A destination that is not in the file has no ceiling and is refused. That is the
//! deny-by-default the paper asks for.
pub mod decide;
pub mod record;
pub mod scan;
use dm_core::{Label, ReadFilter};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
/// What can go wrong with a grants file.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The file could not be read.
#[error("grants file {path}: {source}")]
Io {
/// The path.
path: String,
/// Why.
source: std::io::Error,
},
/// The file is not valid TOML, or does not fit the schema.
#[error("grants file: {0}")]
Parse(#[from] toml::de::Error),
/// The file could not be rendered, which the schema should make impossible.
#[error("grants file: {0}")]
Render(#[from] toml::ser::Error),
/// A value is well-formed but not allowed.
#[error("grants file: {0}")]
Invalid(String),
/// The policy would not compile or evaluate.
#[error("policy: {0}")]
Policy(String),
}
/// What one destination's model calls may spend, in tokens in and out: per session,
/// and across the root in the last day. Checked by the egress before a call leaves.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpendLimit {
/// Tokens one session may spend here.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_tokens: Option<u64>,
/// Tokens the root may spend here in the last day.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub day_tokens: Option<u64>,
}
/// The gates on writes whose blast radius is not routine; `[gates]` in the file.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Gates {
/// How many entries one agent session may supersede before the rest is the
/// owner's to admit.
#[serde(default = "default_max_supersedes")]
pub max_supersedes_per_session: usize,
/// Whether replacing a verified entry needs an approver who is not the proposer.
#[serde(default = "default_true")]
pub verified_needs_approver: bool,
}
fn default_max_supersedes() -> usize {
20
}
fn default_true() -> bool {
true
}
impl Default for Gates {
fn default() -> Gates {
Gates {
max_supersedes_per_session: default_max_supersedes(),
verified_needs_approver: default_true(),
}
}
}
/// One domain's settings.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DomainGrant {
/// What a write gets when the entry states no classification.
#[serde(default)]
pub default_classification: Label,
/// Whether a task a session files from a task it holds, a subagent's work or the
/// continuation of its own, is admitted as it is filed. Tasks from outside, a tap's
/// say, stay proposed until the owner admits them either way.
#[serde(default)]
pub admit_own: bool,
/// How many days a session's transcript is kept whole in the record before the turns
/// no entry cites are dropped; [`DEFAULT_TRANSCRIPT_RETENTION_DAYS`] when unset. The
/// owner sets it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transcript_retention_days: Option<u32>,
}
/// How long a transcript is kept whole when the domain says nothing.
pub const DEFAULT_TRANSCRIPT_RETENTION_DAYS: u32 = 30;
impl DomainGrant {
/// The domain's transcript retention clock, in days.
#[must_use]
pub fn transcript_retention_days(&self) -> u32 {
self.transcript_retention_days
.unwrap_or(DEFAULT_TRANSCRIPT_RETENTION_DAYS)
}
}
/// One destination's settings.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Destination {
/// The highest classification this destination may receive.
pub ceiling: Label,
}
/// What one tap may do: a connector the wrapper hosts as a WASI component.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TapGrant {
/// The domain its proposals land in.
pub domain: String,
/// Hosts it may reach, as `host` or `host:port`; nothing else answers.
#[serde(default)]
pub hosts: Vec<String>,
/// The highest classification it may send out.
pub ceiling: Label,
/// The environment variable holding the tracker's credential, which the host
/// injects as a bearer token; the tap never sees it. The file names the variable,
/// never the secret.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential: Option<String>,
/// A tool an agent calls mid-turn, such as fetch or search, rather than a
/// connector run on a schedule. A tool's hosts may be patterns, `*.apache.org`, and
/// every request it makes is decided by the policy's `fetch` action.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tool: bool,
/// Hosts trusted further: a session may reach them up to this ceiling, above the
/// tool's own, since what a URL or a query carries goes only to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted: Option<Trusted>,
/// The methods a tool may send; `GET` when empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub methods: Vec<String>,
/// The header the credential goes in, as `X-Subscription-Token`; `Authorization`,
/// as a bearer, when unset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credential_header: Option<String>,
/// Whether a tool may reach this machine's loopback, for a fake in development.
/// Private and link-local addresses stay refused either way.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub loopback: bool,
/// The tool's own configuration, JSON, handed to every call.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<String>,
/// The classification what a tool returns is kept at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub results: Option<Label>,
/// Every call of the tool goes out, its answer kept as a source but never answered
/// from: for a tool whose answer changes from one call to the next, as a run's
/// state does.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub fresh: bool,
/// A tool another host serves: the MCP endpoint of a tool server, called instead of
/// running the component here. `credential` then names the variable holding the
/// bearer for that server, which holds the tool's own credential itself; the
/// policy still decides the call, and every URL the server reports it reached.
/// `https`, or `http` to this machine's loopback for development.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hosted: Option<String>,
/// A tool the owner's own browser runs: fetch, made by the companion extension
/// beside the web launcher's page with the browser's own cookies, so a page the
/// owner is signed in to answers as it would to them. The browser is a host of the
/// tool as a tool server is, not a way around the grant: the policy decides the URL
/// before the browser is asked and the URL that answered before anything is kept,
/// and the hosts the session may reach go with every ask.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub browser: bool,
}
/// Hosts a tool may reach at a higher label than its own ceiling.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Trusted {
/// Hosts or patterns, as the tool's own.
pub hosts: Vec<String>,
/// The highest label of a session that may reach them.
pub ceiling: Label,
}
/// The grants file, as parsed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grants {
/// The owner, system-qualified. The author of everything written here by a person.
pub owner: String,
/// The highest classification the owner reads.
#[serde(default = "restricted")]
pub handling: Label,
/// The domain that holds the platform: agent definitions, prompt templates, eval
/// suites and harness configurations, loaded by the launcher only once verified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
/// Domains the owner holds, by id.
#[serde(default)]
pub domains: BTreeMap<String, DomainGrant>,
/// Places the agent may send to, by name.
#[serde(default)]
pub destinations: BTreeMap<String, Destination>,
/// Taps the wrapper may run, by name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub taps: BTreeMap<String, TapGrant>,
/// The gates on writes; defaults apply when the table is absent.
#[serde(default)]
pub gates: Gates,
/// Spend limits per resource class, by destination: `[spend.llmao]`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub spend: BTreeMap<String, SpendLimit>,
/// Where the task ledger lives when not in the SQLite file under the root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ledger: Option<LedgerSettings>,
}
/// The ledger's backend, when it is a server rather than the file under the root:
///
/// ```toml
/// [ledger]
/// url = "postgres://memory:secret@localhost:5432/memory"
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LedgerSettings {
/// A PostgreSQL connection URL. The tables are created on first use.
pub url: String,
}
fn restricted() -> Label {
Label::Restricted
}
impl Grants {
/// A starting file for one owner and one domain: the owner reads everything, the
/// domain's writes default to internal, and the model destinations a laptop uses
/// accept up to internal. Restricted material stays home until the owner raises
/// a ceiling by hand.
#[must_use]
pub fn starter(owner: &str, domain: &str) -> Grants {
let mut domains = BTreeMap::new();
domains.insert(
domain.to_string(),
DomainGrant {
default_classification: Label::Internal,
admit_own: true,
transcript_retention_days: None,
},
);
let mut destinations = BTreeMap::new();
for d in [
"anthropic",
"openai",
"chatgpt",
"gemini",
"google",
"github",
"xai",
"dashscope",
"grok",
"meta",
"llmao",
] {
destinations.insert(
d.to_string(),
Destination {
ceiling: Label::Internal,
},
);
}
Grants {
owner: owner.to_string(),
handling: Label::Restricted,
platform: None,
domains,
destinations,
taps: BTreeMap::new(),
ledger: None,
gates: Gates::default(),
spend: BTreeMap::new(),
}
}
/// Parse the file's text.
///
/// # Errors
///
/// [`Error::Parse`] for bad TOML or a value outside the schema, [`Error::Invalid`] for
/// an empty owner or no domains.
pub fn parse(text: &str) -> Result<Grants, Error> {
let g: Grants = toml::from_str(text)?;
if g.owner.trim().is_empty() {
return Err(Error::Invalid("owner is required".into()));
}
if g.domains.is_empty() {
return Err(Error::Invalid("at least one domain is required".into()));
}
for (name, t) in &g.taps {
if let Some(url) = &t.hosted {
let local = ["http://127.0.0.1", "http://localhost", "http://[::1]"]
.iter()
.any(|p| {
url.strip_prefix(p)
.is_some_and(|r| r.is_empty() || r.starts_with([':', '/']))
});
if !t.tool || !(url.starts_with("https://") || local) {
return Err(Error::Invalid(format!(
"[taps.{name}] hosted = {url:?}: a hosted tool is a tool, served over https or on this machine's loopback"
)));
}
}
if t.browser {
if !t.tool || name != "fetch" {
return Err(Error::Invalid(format!(
"[taps.{name}] browser = true: the browser runs fetch, granted as a tool"
)));
}
if t.hosted.is_some() {
return Err(Error::Invalid(format!(
"[taps.{name}]: browser = true or hosted = \"...\"; a tool has one host"
)));
}
}
}
if let Some(p) = g
.platform
.as_deref()
.filter(|p| !g.domains.contains_key(*p))
{
return Err(Error::Invalid(format!(
"platform = {p:?} names a domain the owner does not hold"
)));
}
Ok(g)
}
/// Read and parse the file.
///
/// # Errors
///
/// [`Error::Io`] when it cannot be read, or what [`Grants::parse`] returns.
pub fn load(path: &Path) -> Result<Grants, Error> {
let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
path: path.display().to_string(),
source,
})?;
Grants::parse(&text)
}
/// Render as TOML, for `amem init` to write.
///
/// # Errors
///
/// [`Error::Render`], which the schema should make impossible.
pub fn render(&self) -> Result<String, Error> {
Ok(toml::to_string_pretty(self)?)
}
/// The filter a session under these grants reads with.
#[must_use]
pub fn read_filter(&self) -> ReadFilter {
ReadFilter::for_domains(self.domains.keys().cloned(), self.handling)
}
/// A tap's grant, if the file names it and its domain is one the owner holds.
#[must_use]
pub fn tap(&self, name: &str) -> Option<&TapGrant> {
self.taps.get(name).filter(|t| self.may_write(&t.domain))
}
/// Whether the owner may write into a domain.
#[must_use]
pub fn may_write(&self, domain: &str) -> bool {
self.domains.contains_key(domain)
}
/// The classification a write gets: what the entry asked for, or the domain's
/// default, raised to the session's label so that nothing written is lower than what
/// its author read. `None` when the domain is not granted.
#[must_use]
pub fn classification_for_write(
&self,
domain: &str,
requested: Option<Label>,
session: &Session,
) -> Option<Label> {
let d = self.domains.get(domain)?;
Some(
requested
.unwrap_or(d.default_classification)
.max(session.label),
)
}
/// The ceiling of a destination, if it is granted at all.
#[must_use]
pub fn ceiling(&self, destination: &str) -> Option<Label> {
self.destinations.get(destination).map(|d| d.ceiling)
}
/// A fresh session under these grants, with its label at public.
#[must_use]
pub fn session(&self) -> Session {
Session {
handling: self.handling,
label: Label::Public,
}
}
}
/// What one session has read so far, and what it may therefore do.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Session {
/// The highest classification this session may read. Fixed at the start.
pub handling: Label,
/// The highest classification it has read. Rises, never falls.
pub label: Label,
}
impl Session {
/// Record that the session read something at this classification. The label rises to
/// it and never falls. Reading above handling cannot happen, because the filter
/// never returned it; if a caller reports one anyway, the label is capped and the
/// report is returned as `false` so the caller can treat it as the bug it is.
pub fn observe(&mut self, classification: Label) -> bool {
if classification > self.handling {
self.label = self.handling;
return false;
}
self.label = self.label.max(classification);
true
}
/// Whether this session may send to a destination with the given ceiling. `None`
/// means the destination is not granted, and the answer is no.
#[must_use]
pub fn may_send(&self, ceiling: Option<Label>) -> bool {
ceiling.is_some_and(|c| self.label <= c)
}
}
#[cfg(test)]
mod tests {
use super::*;
const FILE: &str = r#"
owner = "human:local:striker"
handling = "internal"
[domains.dom-striker]
default_classification = "internal"
transcript_retention_days = 7
[domains.dom-notes]
[destinations.github]
ceiling = "public"
[destinations.notes]
ceiling = "restricted"
[taps.github-issues]
domain = "dom-striker"
hosts = ["api.github.com"]
ceiling = "public"
credential = "GH_TOKEN"
[taps.stray]
domain = "dom-else"
ceiling = "public"
"#;
#[test]
fn a_tap_grant_names_a_held_domain_and_round_trips() {
let g = Grants::parse(FILE).unwrap();
let t = g.tap("github-issues").unwrap();
assert_eq!(t.domain, "dom-striker");
assert_eq!(t.hosts, ["api.github.com"]);
assert_eq!(t.ceiling, Label::Public);
assert_eq!(t.credential.as_deref(), Some("GH_TOKEN"));
assert!(
g.tap("stray").is_none(),
"a tap on a domain the owner does not hold"
);
assert!(g.tap("nope").is_none());
let again = Grants::parse(&g.render().unwrap()).unwrap();
assert_eq!(again, g);
let starter = Grants::starter("human:x:y", "dom-y");
assert!(starter.taps.is_empty());
assert!(
!starter.render().unwrap().contains("[taps"),
"no section until one is granted"
);
}
#[test]
fn a_browser_grant_is_fetch_as_a_tool_with_no_other_host() {
let base = "owner = \"human:x:y\"\n[domains.dom-y]\n";
let g = Grants::parse(&format!(
"{base}[taps.fetch]\ndomain = \"dom-y\"\nceiling = \"public\"\ntool = true\nbrowser = true\n"
))
.unwrap();
assert!(g.taps["fetch"].browser);
assert!(g.render().unwrap().contains("browser = true"));
for (grant, why) in [
(
"[taps.search]\ndomain = \"dom-y\"\nceiling = \"public\"\ntool = true\nbrowser = true\n",
"the browser runs fetch",
),
(
"[taps.fetch]\ndomain = \"dom-y\"\nceiling = \"public\"\nbrowser = true\n",
"granted as a tool",
),
(
"[taps.fetch]\ndomain = \"dom-y\"\nceiling = \"public\"\ntool = true\nbrowser = true\nhosted = \"https://t.example/mcp\"\n",
"one host",
),
] {
let e = Grants::parse(&format!("{base}{grant}"))
.unwrap_err()
.to_string();
assert!(e.contains(why), "{grant}: {e}");
}
}
#[test]
fn parses_and_compiles_to_a_filter() {
let g = Grants::parse(FILE).unwrap();
assert_eq!(g.owner, "human:local:striker");
assert_eq!(g.handling, Label::Internal);
let f = g.read_filter();
assert_eq!(f.domains, vec!["dom-notes", "dom-striker"]);
assert_eq!(f.handling, Label::Internal);
assert!(!f.include_inactive);
assert!(g.may_write("dom-striker"));
assert!(!g.may_write("dom-else"));
assert_eq!(
g.domains["dom-notes"].default_classification,
Label::Public,
"a domain table with nothing in it defaults to public"
);
assert_eq!(g.domains["dom-striker"].transcript_retention_days(), 7);
assert_eq!(
g.domains["dom-notes"].transcript_retention_days(),
DEFAULT_TRANSCRIPT_RETENTION_DAYS,
"a domain that says nothing keeps transcripts thirty days"
);
}
#[test]
fn the_label_rises_and_never_falls_and_gates_sending() {
let g = Grants::parse(FILE).unwrap();
let mut s = g.session();
assert_eq!(s.label, Label::Public);
assert!(s.may_send(g.ceiling("github")));
assert!(s.observe(Label::Internal));
assert_eq!(s.label, Label::Internal);
assert!(
s.observe(Label::Public),
"reading something public changes nothing"
);
assert_eq!(s.label, Label::Internal);
assert!(
!s.may_send(g.ceiling("github")),
"internal exceeds a public ceiling"
);
assert!(s.may_send(g.ceiling("notes")));
assert!(
!s.may_send(g.ceiling("nowhere")),
"an ungranted destination is refused"
);
assert!(!s.may_send(None));
}
#[test]
fn a_read_above_handling_is_reported_and_capped() {
let g = Grants::parse(FILE).unwrap();
let mut s = g.session();
assert!(!s.observe(Label::Restricted));
assert_eq!(s.label, Label::Internal);
}
#[test]
fn writes_inherit_the_session_label() {
let g = Grants::parse(FILE).unwrap();
let mut s = g.session();
assert_eq!(
g.classification_for_write("dom-notes", None, &s),
Some(Label::Public),
"domain default"
);
assert_eq!(
g.classification_for_write("dom-striker", None, &s),
Some(Label::Internal)
);
assert_eq!(
g.classification_for_write("dom-notes", Some(Label::Internal), &s),
Some(Label::Internal),
"the entry may ask for more"
);
s.observe(Label::Internal);
assert_eq!(
g.classification_for_write("dom-notes", Some(Label::Public), &s),
Some(Label::Internal),
"never lower than what the author read"
);
assert_eq!(g.classification_for_write("dom-else", None, &s), None);
}
#[test]
fn starter_round_trips_and_keeps_restricted_home() {
let g = Grants::starter("human:local:me", "dom-me");
let text = g.render().unwrap();
let back = Grants::parse(&text).unwrap();
assert_eq!(back, g);
assert_eq!(back.handling, Label::Restricted);
assert_eq!(back.ceiling("anthropic"), Some(Label::Internal));
assert_eq!(back.ceiling("llmao"), Some(Label::Internal));
assert!(!back.session().may_send(back.ceiling("anything")));
let mut s = back.session();
s.observe(Label::Restricted);
assert!(
!s.may_send(back.ceiling("anthropic")),
"restricted stays home"
);
assert!(text.contains("[domains.dom-me]"), "{text}");
assert!(text.contains("[destinations.anthropic]"), "{text}");
// every destination the launcher's harnesses route to by default has a ceiling
for d in ["dashscope", "grok", "google", "chatgpt", "github", "xai"] {
assert_eq!(back.ceiling(d), Some(Label::Internal), "{d}");
}
assert_eq!(back.ceiling("qwen"), None, "no such destination");
}
#[test]
fn bad_files_are_errors() {
assert!(matches!(Grants::parse("owner = 1"), Err(Error::Parse(_))));
assert!(matches!(
Grants::parse("owner = \"x\"\nhandling = \"secret\"\n[domains.a]\n"),
Err(Error::Parse(_))
));
assert!(matches!(
Grants::parse("owner = \"\"\n[domains.a]\n"),
Err(Error::Invalid(_))
));
assert!(matches!(
Grants::parse("owner = \"x\"\n"),
Err(Error::Invalid(_))
));
assert!(matches!(
Grants::load(Path::new("/nonexistent/grants.toml")),
Err(Error::Io { .. })
));
}
}