brokerd: policy and ledger as modules, before the memory tools
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
//! Matching a call against the grants: which grants cover it, and which one wins.
|
||||
|
||||
use super::{Label, Matched, SessionState};
|
||||
use crate::args::{ToolArgs, host_matches, inside};
|
||||
use crate::grants::GrantSet;
|
||||
use proto::{DataClass, Grant, Hash32, Mode, Timestamp};
|
||||
|
||||
/// The grants left after matching, and the two flags that pick the denial reason when none are
|
||||
/// left.
|
||||
pub(super) struct MatchResult {
|
||||
pub(super) left: Vec<Candidate>,
|
||||
pub(super) expired: bool,
|
||||
pub(super) tainted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Candidate {
|
||||
pub(super) id: String,
|
||||
pub(super) grant_sha256: Hash32,
|
||||
pub(super) mode: Mode,
|
||||
pub(super) matched_path: Option<String>,
|
||||
pub(super) paths: Vec<String>,
|
||||
pub(super) hosts: Vec<String>,
|
||||
pub(super) expires: Option<Timestamp>,
|
||||
pub(super) result_class: DataClass,
|
||||
pub(super) untrusted: bool,
|
||||
}
|
||||
|
||||
/// Match a call against every grant, in id order, keeping the ones that still stand.
|
||||
pub(super) fn match_grants(
|
||||
args: &ToolArgs,
|
||||
grants: &GrantSet,
|
||||
state: SessionState,
|
||||
now: Timestamp,
|
||||
) -> MatchResult {
|
||||
let mut left: Vec<Candidate> = Vec::new();
|
||||
let mut expired = false;
|
||||
let mut tainted = false;
|
||||
|
||||
for loaded in grants.grants() {
|
||||
// M1: only grants for this tool play a part.
|
||||
if loaded.grant.tool != args.tool().as_str() {
|
||||
continue;
|
||||
}
|
||||
// M2: does this grant cover the arguments, and with which path (if any)?
|
||||
let Some(matched_path) = covers(args, &loaded.grant) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// M3: a grant that would match but is past its expiry or beyond the session's taint does
|
||||
// not stand. Remember each reason, so the denial can name the right one.
|
||||
let expired_at = loaded.grant.expires.is_some_and(|at| now >= at);
|
||||
let too_tainted = state.taint > loaded.grant.max_taint;
|
||||
if expired_at && !too_tainted {
|
||||
expired = true;
|
||||
}
|
||||
if too_tainted && !expired_at {
|
||||
tainted = true;
|
||||
}
|
||||
if !expired_at && !too_tainted {
|
||||
left.push(Candidate {
|
||||
id: loaded.id.clone(),
|
||||
grant_sha256: loaded.sha256,
|
||||
mode: loaded.grant.mode,
|
||||
matched_path,
|
||||
paths: loaded.grant.constraints.paths.clone(),
|
||||
hosts: loaded.grant.constraints.hosts.clone(),
|
||||
expires: loaded.grant.expires,
|
||||
result_class: loaded.grant.result_class,
|
||||
untrusted: loaded.grant.untrusted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
MatchResult {
|
||||
left,
|
||||
expired,
|
||||
tainted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the grant covers the call, and the longest of its paths that holds the call. `None`
|
||||
/// means the grant does not cover the call at all.
|
||||
fn covers(args: &ToolArgs, grant: &Grant) -> Option<Option<String>> {
|
||||
match args {
|
||||
ToolArgs::ReadFile { path } => best_path(grant, path, true),
|
||||
ToolArgs::WriteFile { path, .. } => best_path(grant, path, false),
|
||||
ToolArgs::Shell { cwd: None, .. } => {
|
||||
if grant.constraints.paths.is_empty() {
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true),
|
||||
ToolArgs::HttpFetch(target) => {
|
||||
if grant
|
||||
.constraints
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|pattern| host_matches(pattern, target.host()))
|
||||
{
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the
|
||||
/// argument does not count.
|
||||
fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option<Option<String>> {
|
||||
let mut best: Option<String> = None;
|
||||
for candidate in &grant.constraints.paths {
|
||||
if !inside(candidate, target) {
|
||||
continue;
|
||||
}
|
||||
if !itself_counts && candidate == target {
|
||||
continue;
|
||||
}
|
||||
if best
|
||||
.as_ref()
|
||||
.is_none_or(|found| candidate.len() > found.len())
|
||||
{
|
||||
best = Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
// `None` means no path holds, so the grant does not cover the call; a held path becomes
|
||||
// `Some(Some(p))` so `covers` can tell it apart from a covered call with no path.
|
||||
best.map(Some)
|
||||
}
|
||||
|
||||
/// Build the winning grant's `Matched`, with the label over every standing grant.
|
||||
pub(super) fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched {
|
||||
Matched {
|
||||
grant: winner.id.clone(),
|
||||
grant_sha256: winner.grant_sha256,
|
||||
matched_path: winner.matched_path.clone(),
|
||||
paths: winner.paths.clone(),
|
||||
hosts: winner.hosts.clone(),
|
||||
expires: winner.expires,
|
||||
label: combined_label(left),
|
||||
}
|
||||
}
|
||||
|
||||
/// The most restrictive standing grant, then the longest matched path, then the lowest id.
|
||||
pub(super) fn winner(candidates: &[Candidate]) -> Option<Candidate> {
|
||||
for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() {
|
||||
let mut best: Option<&Candidate> = None;
|
||||
for candidate in candidates.iter().filter(|c| c.mode == mode) {
|
||||
best = Some(match best {
|
||||
Some(found) if !better(candidate, found) => found,
|
||||
_ => candidate,
|
||||
});
|
||||
}
|
||||
if let Some(found) = best {
|
||||
return Some(found.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id.
|
||||
fn better(a: &Candidate, b: &Candidate) -> bool {
|
||||
let (la, lb) = (
|
||||
a.matched_path.as_ref().map_or(0, String::len),
|
||||
b.matched_path.as_ref().map_or(0, String::len),
|
||||
);
|
||||
match la.cmp(&lb) {
|
||||
std::cmp::Ordering::Greater => true,
|
||||
std::cmp::Ordering::Less => false,
|
||||
std::cmp::Ordering::Equal => a.id < b.id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The label over every standing grant: the highest data class, and untrusted if any says so.
|
||||
fn combined_label(candidates: &[Candidate]) -> Label {
|
||||
let mut class = DataClass::Public;
|
||||
for candidate in candidates {
|
||||
if candidate.result_class > class {
|
||||
class = candidate.result_class;
|
||||
}
|
||||
}
|
||||
let untrusted = candidates.iter().any(|c| c.untrusted);
|
||||
Label { class, untrusted }
|
||||
}
|
||||
Reference in New Issue
Block a user