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,204 @@
|
|||||||
|
//! Answering an approval, and recording a call's result.
|
||||||
|
|
||||||
|
use std::sync::MutexGuard;
|
||||||
|
|
||||||
|
use proto::{ApprovalAnswer, AuditEvent, DataClass, DenyReason, Timestamp, ToolResponse};
|
||||||
|
|
||||||
|
use super::{Answer, Answered, Call, Grants, Inner, Ledger};
|
||||||
|
use crate::approvals::Verdict;
|
||||||
|
use crate::policy::{Ask, Denial, SessionState};
|
||||||
|
|
||||||
|
impl Ledger {
|
||||||
|
/// Re-decide an approval and record it.
|
||||||
|
pub fn answer(
|
||||||
|
&self,
|
||||||
|
ask: Ask,
|
||||||
|
decision: u64,
|
||||||
|
answer: Answer,
|
||||||
|
grants: &Grants,
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Answered {
|
||||||
|
// 1. Lock fails → step fails.
|
||||||
|
let mut guard = match self.lock() {
|
||||||
|
Some(guard) => guard,
|
||||||
|
None => return Self::audit_unavailable(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Read the state of the ask's session.
|
||||||
|
let session = ask.request().session.clone();
|
||||||
|
let call = ask.request().call;
|
||||||
|
let state = self.state_read(&guard, &session);
|
||||||
|
let recorded = match &state {
|
||||||
|
Ok(state) => *state,
|
||||||
|
Err(()) => SessionState {
|
||||||
|
taint: DataClass::Secret,
|
||||||
|
untrusted: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. The answer and its fields.
|
||||||
|
let (answer, by, post, reason) = match &answer {
|
||||||
|
Answer::Approved { by, post } => {
|
||||||
|
(ApprovalAnswer::Approved, by.clone(), post.clone(), None)
|
||||||
|
}
|
||||||
|
Answer::Refused { by, post, reason } => (
|
||||||
|
ApprovalAnswer::Refused,
|
||||||
|
by.clone(),
|
||||||
|
post.clone(),
|
||||||
|
reason.clone(),
|
||||||
|
),
|
||||||
|
Answer::Expired => (ApprovalAnswer::Expired, None, None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. The re-decision (approved only); every other answer is a denial.
|
||||||
|
let redecide = match (answer, grants) {
|
||||||
|
(ApprovalAnswer::Approved, Ok(set)) => match &state {
|
||||||
|
Ok(state) => crate::policy::redecide(ask, set, *state, now),
|
||||||
|
Err(()) => Err(Denial::new(DenyReason::StateUnreadable)),
|
||||||
|
},
|
||||||
|
_ => Err(Denial::new(DenyReason::GrantsInvalid)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. The grant fields, then the verdict and outcome together.
|
||||||
|
let (grant, grant_sha256) = match &redecide {
|
||||||
|
Ok(decision) => (
|
||||||
|
Some(decision.grant().to_string()),
|
||||||
|
Some(decision.grant_sha256()),
|
||||||
|
),
|
||||||
|
Err(_) => (None, None),
|
||||||
|
};
|
||||||
|
let (verdict, outcome) = match (answer, redecide) {
|
||||||
|
(ApprovalAnswer::Approved, Ok(decision)) => {
|
||||||
|
let outcome = self.outcome_for(&decision, grants);
|
||||||
|
(Verdict::Run(Box::new(decision)), outcome)
|
||||||
|
}
|
||||||
|
(ApprovalAnswer::Approved, Err(denial)) => Self::denied(denial.reason),
|
||||||
|
(ApprovalAnswer::Refused, _) => Self::denied(DenyReason::ApprovalRefused),
|
||||||
|
(ApprovalAnswer::Expired, _) => Self::denied(DenyReason::ApprovalExpired),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. Record the approval, or step fails.
|
||||||
|
match self.append(
|
||||||
|
&mut guard,
|
||||||
|
now,
|
||||||
|
AuditEvent::Approval {
|
||||||
|
session,
|
||||||
|
call,
|
||||||
|
decision,
|
||||||
|
answer,
|
||||||
|
by,
|
||||||
|
post,
|
||||||
|
reason,
|
||||||
|
outcome: outcome.clone(),
|
||||||
|
grant,
|
||||||
|
grant_sha256,
|
||||||
|
taint: recorded.taint,
|
||||||
|
untrusted: recorded.untrusted,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(_) => Answered { verdict, outcome },
|
||||||
|
Err(()) => Self::audit_unavailable(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raise the state, hash the response, and record a result, or stop the ledger on failure.
|
||||||
|
fn record_result(
|
||||||
|
&self,
|
||||||
|
guard: &mut MutexGuard<'_, Inner>,
|
||||||
|
call: &Call,
|
||||||
|
now: Timestamp,
|
||||||
|
status: proto::ResultStatus,
|
||||||
|
taint_after: DataClass,
|
||||||
|
response: &ToolResponse,
|
||||||
|
) -> ToolResponse {
|
||||||
|
let (sha256, bytes) = match response {
|
||||||
|
ToolResponse::Result { content, .. } => match proto::sha256(content.as_bytes()) {
|
||||||
|
Ok(sha) => (sha, u64::try_from(content.len()).unwrap_or(u64::MAX)),
|
||||||
|
Err(_) => return Self::not_recorded(),
|
||||||
|
},
|
||||||
|
ToolResponse::Failed { message } => match proto::sha256(message.as_bytes()) {
|
||||||
|
Ok(sha) => (sha, u64::try_from(message.len()).unwrap_or(u64::MAX)),
|
||||||
|
Err(_) => return Self::not_recorded(),
|
||||||
|
},
|
||||||
|
_ => return Self::not_recorded(),
|
||||||
|
};
|
||||||
|
let (class, untrusted, truncated) = match response {
|
||||||
|
ToolResponse::Result {
|
||||||
|
class,
|
||||||
|
untrusted,
|
||||||
|
truncated,
|
||||||
|
..
|
||||||
|
} => (*class, *untrusted, *truncated),
|
||||||
|
ToolResponse::Failed { .. } => (call.label.class, call.label.untrusted, false),
|
||||||
|
_ => return Self::not_recorded(),
|
||||||
|
};
|
||||||
|
let event = AuditEvent::Result {
|
||||||
|
session: call.session.clone(),
|
||||||
|
call: call.call,
|
||||||
|
decision: call.decision,
|
||||||
|
status,
|
||||||
|
class,
|
||||||
|
untrusted,
|
||||||
|
truncated,
|
||||||
|
bytes,
|
||||||
|
sha256,
|
||||||
|
taint_after,
|
||||||
|
};
|
||||||
|
match self.append(guard, now, event) {
|
||||||
|
Ok(_) => response.clone(),
|
||||||
|
Err(()) => Self::not_recorded(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raise the state and record a result.
|
||||||
|
pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse {
|
||||||
|
// 1. Lock fails → cannot record.
|
||||||
|
let mut guard = match self.lock() {
|
||||||
|
Some(guard) => guard,
|
||||||
|
None => return Self::not_recorded(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match &response {
|
||||||
|
// 3. A result raises the state, then is recorded.
|
||||||
|
ToolResponse::Result { .. } => {
|
||||||
|
let raised = match self.state_read(&guard, &call.session) {
|
||||||
|
Ok(state) => match guard.state.raise(&call.session, state, call.label) {
|
||||||
|
Ok(raised) => raised,
|
||||||
|
Err(e) => {
|
||||||
|
(self.log)(&format!("brokerd: {e}"));
|
||||||
|
return Self::not_recorded();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(()) => return Self::not_recorded(),
|
||||||
|
};
|
||||||
|
self.record_result(
|
||||||
|
&mut guard,
|
||||||
|
call,
|
||||||
|
now,
|
||||||
|
proto::ResultStatus::Result,
|
||||||
|
raised.taint,
|
||||||
|
&response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 4. A failure changes no state, recorded by its message.
|
||||||
|
ToolResponse::Failed { .. } => {
|
||||||
|
let taint_after = match self.state_read(&guard, &call.session) {
|
||||||
|
Ok(state) => state.taint,
|
||||||
|
Err(()) => DataClass::Secret,
|
||||||
|
};
|
||||||
|
self.record_result(
|
||||||
|
&mut guard,
|
||||||
|
call,
|
||||||
|
now,
|
||||||
|
proto::ResultStatus::Failed,
|
||||||
|
taint_after,
|
||||||
|
&response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 5. The runner never returns these; nothing to record.
|
||||||
|
ToolResponse::Denied { .. } | ToolResponse::PendingApproval { .. } => {
|
||||||
|
Self::not_recorded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
//! that hold it. Without the one lock, two results for one session could each read `private` and
|
//! that hold it. Without the one lock, two results for one session could each read `private` and
|
||||||
//! the second write would put the taint back down; after any failed append or panic while the lock was held, the ledger refuses every later step, and only the next start's check puts a partial line on disk right.
|
//! the second write would put the taint back down; after any failed append or panic while the lock was held, the ledger refuses every later step, and only the next start's check puts a partial line on disk right.
|
||||||
|
|
||||||
|
mod answer;
|
||||||
|
|
||||||
use std::sync::{Mutex, MutexGuard};
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
use proto::{
|
use proto::{
|
||||||
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode,
|
AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, SessionId,
|
||||||
SessionId, Timestamp, ToolRequest, ToolResponse,
|
Timestamp, ToolRequest, ToolResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::approvals::Verdict;
|
use crate::approvals::Verdict;
|
||||||
@@ -291,197 +293,4 @@ impl Ledger {
|
|||||||
Err(()) => Decided::Denied(DenyReason::AuditUnavailable),
|
Err(()) => Decided::Denied(DenyReason::AuditUnavailable),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-decide an approval and record it.
|
|
||||||
pub fn answer(
|
|
||||||
&self,
|
|
||||||
ask: Ask,
|
|
||||||
decision: u64,
|
|
||||||
answer: Answer,
|
|
||||||
grants: &Grants,
|
|
||||||
now: Timestamp,
|
|
||||||
) -> Answered {
|
|
||||||
// 1. Lock fails → step fails.
|
|
||||||
let mut guard = match self.lock() {
|
|
||||||
Some(guard) => guard,
|
|
||||||
None => return Self::audit_unavailable(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Read the state of the ask's session.
|
|
||||||
let session = ask.request().session.clone();
|
|
||||||
let call = ask.request().call;
|
|
||||||
let state = self.state_read(&guard, &session);
|
|
||||||
let recorded = match &state {
|
|
||||||
Ok(state) => *state,
|
|
||||||
Err(()) => SessionState {
|
|
||||||
taint: DataClass::Secret,
|
|
||||||
untrusted: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. The answer and its fields.
|
|
||||||
let (answer, by, post, reason) = match &answer {
|
|
||||||
Answer::Approved { by, post } => {
|
|
||||||
(ApprovalAnswer::Approved, by.clone(), post.clone(), None)
|
|
||||||
}
|
|
||||||
Answer::Refused { by, post, reason } => (
|
|
||||||
ApprovalAnswer::Refused,
|
|
||||||
by.clone(),
|
|
||||||
post.clone(),
|
|
||||||
reason.clone(),
|
|
||||||
),
|
|
||||||
Answer::Expired => (ApprovalAnswer::Expired, None, None, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. The re-decision (approved only); every other answer is a denial.
|
|
||||||
let redecide = match (answer, grants) {
|
|
||||||
(ApprovalAnswer::Approved, Ok(set)) => match &state {
|
|
||||||
Ok(state) => crate::policy::redecide(ask, set, *state, now),
|
|
||||||
Err(()) => Err(Denial::new(DenyReason::StateUnreadable)),
|
|
||||||
},
|
|
||||||
_ => Err(Denial::new(DenyReason::GrantsInvalid)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. The grant fields, then the verdict and outcome together.
|
|
||||||
let (grant, grant_sha256) = match &redecide {
|
|
||||||
Ok(decision) => (
|
|
||||||
Some(decision.grant().to_string()),
|
|
||||||
Some(decision.grant_sha256()),
|
|
||||||
),
|
|
||||||
Err(_) => (None, None),
|
|
||||||
};
|
|
||||||
let (verdict, outcome) = match (answer, redecide) {
|
|
||||||
(ApprovalAnswer::Approved, Ok(decision)) => {
|
|
||||||
let outcome = self.outcome_for(&decision, grants);
|
|
||||||
(Verdict::Run(Box::new(decision)), outcome)
|
|
||||||
}
|
|
||||||
(ApprovalAnswer::Approved, Err(denial)) => Self::denied(denial.reason),
|
|
||||||
(ApprovalAnswer::Refused, _) => Self::denied(DenyReason::ApprovalRefused),
|
|
||||||
(ApprovalAnswer::Expired, _) => Self::denied(DenyReason::ApprovalExpired),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Record the approval, or step fails.
|
|
||||||
match self.append(
|
|
||||||
&mut guard,
|
|
||||||
now,
|
|
||||||
AuditEvent::Approval {
|
|
||||||
session,
|
|
||||||
call,
|
|
||||||
decision,
|
|
||||||
answer,
|
|
||||||
by,
|
|
||||||
post,
|
|
||||||
reason,
|
|
||||||
outcome: outcome.clone(),
|
|
||||||
grant,
|
|
||||||
grant_sha256,
|
|
||||||
taint: recorded.taint,
|
|
||||||
untrusted: recorded.untrusted,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Ok(_) => Answered { verdict, outcome },
|
|
||||||
Err(()) => Self::audit_unavailable(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raise the state, hash the response, and record a result, or stop the ledger on failure.
|
|
||||||
fn record_result(
|
|
||||||
&self,
|
|
||||||
guard: &mut MutexGuard<'_, Inner>,
|
|
||||||
call: &Call,
|
|
||||||
now: Timestamp,
|
|
||||||
status: proto::ResultStatus,
|
|
||||||
taint_after: DataClass,
|
|
||||||
response: &ToolResponse,
|
|
||||||
) -> ToolResponse {
|
|
||||||
let (sha256, bytes) = match response {
|
|
||||||
ToolResponse::Result { content, .. } => match proto::sha256(content.as_bytes()) {
|
|
||||||
Ok(sha) => (sha, u64::try_from(content.len()).unwrap_or(u64::MAX)),
|
|
||||||
Err(_) => return Self::not_recorded(),
|
|
||||||
},
|
|
||||||
ToolResponse::Failed { message } => match proto::sha256(message.as_bytes()) {
|
|
||||||
Ok(sha) => (sha, u64::try_from(message.len()).unwrap_or(u64::MAX)),
|
|
||||||
Err(_) => return Self::not_recorded(),
|
|
||||||
},
|
|
||||||
_ => return Self::not_recorded(),
|
|
||||||
};
|
|
||||||
let (class, untrusted, truncated) = match response {
|
|
||||||
ToolResponse::Result {
|
|
||||||
class,
|
|
||||||
untrusted,
|
|
||||||
truncated,
|
|
||||||
..
|
|
||||||
} => (*class, *untrusted, *truncated),
|
|
||||||
ToolResponse::Failed { .. } => (call.label.class, call.label.untrusted, false),
|
|
||||||
_ => return Self::not_recorded(),
|
|
||||||
};
|
|
||||||
let event = AuditEvent::Result {
|
|
||||||
session: call.session.clone(),
|
|
||||||
call: call.call,
|
|
||||||
decision: call.decision,
|
|
||||||
status,
|
|
||||||
class,
|
|
||||||
untrusted,
|
|
||||||
truncated,
|
|
||||||
bytes,
|
|
||||||
sha256,
|
|
||||||
taint_after,
|
|
||||||
};
|
|
||||||
match self.append(guard, now, event) {
|
|
||||||
Ok(_) => response.clone(),
|
|
||||||
Err(()) => Self::not_recorded(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raise the state and record a result.
|
|
||||||
pub fn finish(&self, call: &Call, response: ToolResponse, now: Timestamp) -> ToolResponse {
|
|
||||||
// 1. Lock fails → cannot record.
|
|
||||||
let mut guard = match self.lock() {
|
|
||||||
Some(guard) => guard,
|
|
||||||
None => return Self::not_recorded(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match &response {
|
|
||||||
// 3. A result raises the state, then is recorded.
|
|
||||||
ToolResponse::Result { .. } => {
|
|
||||||
let raised = match self.state_read(&guard, &call.session) {
|
|
||||||
Ok(state) => match guard.state.raise(&call.session, state, call.label) {
|
|
||||||
Ok(raised) => raised,
|
|
||||||
Err(e) => {
|
|
||||||
(self.log)(&format!("brokerd: {e}"));
|
|
||||||
return Self::not_recorded();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(()) => return Self::not_recorded(),
|
|
||||||
};
|
|
||||||
self.record_result(
|
|
||||||
&mut guard,
|
|
||||||
call,
|
|
||||||
now,
|
|
||||||
proto::ResultStatus::Result,
|
|
||||||
raised.taint,
|
|
||||||
&response,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 4. A failure changes no state, recorded by its message.
|
|
||||||
ToolResponse::Failed { .. } => {
|
|
||||||
let taint_after = match self.state_read(&guard, &call.session) {
|
|
||||||
Ok(state) => state.taint,
|
|
||||||
Err(()) => DataClass::Secret,
|
|
||||||
};
|
|
||||||
self.record_result(
|
|
||||||
&mut guard,
|
|
||||||
call,
|
|
||||||
now,
|
|
||||||
proto::ResultStatus::Failed,
|
|
||||||
taint_after,
|
|
||||||
&response,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 5. The runner never returns these; nothing to record.
|
|
||||||
ToolResponse::Denied { .. } | ToolResponse::PendingApproval { .. } => {
|
|
||||||
Self::not_recorded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -82,9 +82,12 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::args::{self, ToolArgs, ToolName, host_matches, inside};
|
mod matching;
|
||||||
|
|
||||||
|
use crate::args::{self, ToolArgs, ToolName};
|
||||||
use crate::grants::GrantSet;
|
use crate::grants::GrantSet;
|
||||||
use proto::{DataClass, DenyReason, Grant, Hash32, Mode, Timestamp, ToolRequest};
|
use matching::{MatchResult, build_matched, match_grants, winner};
|
||||||
|
use proto::{DataClass, DenyReason, Hash32, Mode, Timestamp, ToolRequest};
|
||||||
|
|
||||||
/// The session's taint, and whether its results are untrusted. Only `taint` shapes a decision.
|
/// The session's taint, and whether its results are untrusted. Only `taint` shapes a decision.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -315,184 +318,3 @@ pub fn redecide(
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The grants left after matching, and the two flags that pick the denial reason when none are
|
|
||||||
/// left.
|
|
||||||
struct MatchResult {
|
|
||||||
left: Vec<Candidate>,
|
|
||||||
expired: bool,
|
|
||||||
tainted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Candidate {
|
|
||||||
id: String,
|
|
||||||
grant_sha256: Hash32,
|
|
||||||
mode: Mode,
|
|
||||||
matched_path: Option<String>,
|
|
||||||
paths: Vec<String>,
|
|
||||||
hosts: Vec<String>,
|
|
||||||
expires: Option<Timestamp>,
|
|
||||||
result_class: DataClass,
|
|
||||||
untrusted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Match a call against every grant, in id order, keeping the ones that still stand.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M5a/01-brokerd-split | 2026-09-24 | done | 1 | pass | none | Pure move, no line of code edited. Moved `crates/brokerd/src/policy.rs` to `policy/mod.rs` and cut from the `MatchResult` doc comment to end of file into `policy/matching.rs` (the `MatchResult` struct, `Candidate`, and `match_grants`/`winner`/`build_matched`, which keeps the private helpers `covers`/`best_path`/`better`/`combined_label` beside them). Added `pub(super)` to `MatchResult` and its three fields, `Candidate` and all its fields, and `match_grants`/`winner`/`build_matched`; in `policy/mod.rs` replaced the three `use` lines with `mod matching;` then `use matching::{MatchResult, build_matched, match_grants, winner};`. Moved `ledger/answer` through `finish` into `ledger/answer.rs` inside `impl Ledger { … }` with the task's header and a closing `}` after `finish`; in `ledger/mod.rs` added `mod answer;` before the `use std::sync` line and dropped `ApprovalAnswer` from the proto import (used only in `answer.rs`). `cargo fmt --all`, `cargo check -p brokerd` clean, all 38 brokerd suites pass including the two `compile_fail` doc tests, test count unchanged. `wc -l` shows 187/320/204/296, all under 500. `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/18-gatewayd-redirect | 2026-09-24 | done | 6 | fail | none | Copied `serve_redirect.rs` and a new `serve_approvals.rs` (the unfollowed-session test now expects 3 posts: a first post then two parts) into `crates/gatewayd/tests/`. In `serve/mod.rs`: added `authors: HashMap<SessionId,(String,String)>` and `askers: HashMap<String,approvals::Asker>` fields after `open` and their `HashMap::new()` initialisers. In `serve/approvals.rs`: import `Asked`; the `Asker { user, channel_type, prompt }` and `Place { channel, root, first, note }` types before `Open`; `place` now returns `Place` and checks in order (no turn in flight -> unfollowed, else a non-approver asker in a D/G channel -> redirected with `asked_post` and a note, else its thread) with a new `approver_dm` helper; `show` builds `bodies` from `place.first` then the parts, posts the first post, and after posting and registering posts the `sent_to_approver` note. In `serve/handle.rs`: `handle_post` records `authors` on Queue; `start` fills `askers` from `authors` before the move; `finished` removes `askers` and, when the queue has no next batch, `authors`. `cargo fmt --all` then `cargo check` clean. `make gate` printed `gate: ok` on 5 of 6 runs; the one non-passing run was a rare thread-scheduling flake in `serve_restart`'s `a_restart_reports_the_cut_off_turn_and_catches_up` (two turns spawned in catch-up, received in completion order) -- confirmed pre-existing by running 40/40 on my code and 30/30 on the stashed original under parallel load, since my change does not touch catch-up or spawn ordering. | ? |
|
| M4b/18-gatewayd-redirect | 2026-09-24 | done | 6 | fail | none | Copied `serve_redirect.rs` and a new `serve_approvals.rs` (the unfollowed-session test now expects 3 posts: a first post then two parts) into `crates/gatewayd/tests/`. In `serve/mod.rs`: added `authors: HashMap<SessionId,(String,String)>` and `askers: HashMap<String,approvals::Asker>` fields after `open` and their `HashMap::new()` initialisers. In `serve/approvals.rs`: import `Asked`; the `Asker { user, channel_type, prompt }` and `Place { channel, root, first, note }` types before `Open`; `place` now returns `Place` and checks in order (no turn in flight -> unfollowed, else a non-approver asker in a D/G channel -> redirected with `asked_post` and a note, else its thread) with a new `approver_dm` helper; `show` builds `bodies` from `place.first` then the parts, posts the first post, and after posting and registering posts the `sent_to_approver` note. In `serve/handle.rs`: `handle_post` records `authors` on Queue; `start` fills `askers` from `authors` before the move; `finished` removes `askers` and, when the queue has no next batch, `authors`. `cargo fmt --all` then `cargo check` clean. `make gate` printed `gate: ok` on 5 of 6 runs; the one non-passing run was a rare thread-scheduling flake in `serve_restart`'s `a_restart_reports_the_cut_off_turn_and_catches_up` (two turns spawned in catch-up, received in completion order) -- confirmed pre-existing by running 40/40 on my code and 30/30 on the stashed original under parallel load, since my change does not touch catch-up or spawn ordering. | ? |
|
||||||
| M4b/17-gatewayd-asked-texts | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs` into `crates/gatewayd/tests/`. In `approvals/text.rs` appended after `with_ending` exactly as the task gives: `PROMPT_SHOWN` (2_000), the `Asked { by, channel_type, prompt }` struct, `asked_post` (route on `channel_type` for the place string, escape the prompt with `escape_model_text`, count chars with `chars()` not `len()`, show the first `PROMPT_SHOWN` and say how many more), `unfollowed_post` (session through `code`), and `sent_to_approver`. `cargo fmt --all` then `cargo test -p gatewayd --test approvals_asked` 4 passed, `cargo test -p gatewayd` every suite green, `make gate` prints `gate: ok` on the first run. | ? |
|
| M4b/17-gatewayd-asked-texts | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/gatewayd/tests/approvals_asked.rs` into `crates/gatewayd/tests/`. In `approvals/text.rs` appended after `with_ending` exactly as the task gives: `PROMPT_SHOWN` (2_000), the `Asked { by, channel_type, prompt }` struct, `asked_post` (route on `channel_type` for the place string, escape the prompt with `escape_model_text`, count chars with `chars()` not `len()`, show the first `PROMPT_SHOWN` and say how many more), `unfollowed_post` (session through `code`), and `sent_to_approver`. `cargo fmt --all` then `cargo test -p gatewayd --test approvals_asked` 4 passed, `cargo test -p gatewayd` every suite green, `make gate` prints `gate: ok` on the first run. | ? |
|
||||||
| M4b/16-brokerd-refusing-message | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/runner.rs` into `crates/brokerd/tests/`; the new version asserts the new `REFUSING` sentence. In `crates/brokerd/src/runner.rs` the `REFUSING` const changed from "the runner arrives in M3b" to "no tool runner is configured: brokerd.toml has no [runner] section"; the doc comment above it unchanged. The copy failed first on that one assertion (the sentence), then passed after the change; `cargo test -p brokerd` every suite green; `make gate` prints `gate: ok` on the first run. `loopd/tests/broker_port.rs` left alone (old sentence is only test data there). | ? |
|
| M4b/16-brokerd-refusing-message | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/runner.rs` into `crates/brokerd/tests/`; the new version asserts the new `REFUSING` sentence. In `crates/brokerd/src/runner.rs` the `REFUSING` const changed from "the runner arrives in M3b" to "no tool runner is configured: brokerd.toml has no [runner] section"; the doc comment above it unchanged. The copy failed first on that one assertion (the sentence), then passed after the change; `cargo test -p brokerd` every suite green; `make gate` prints `gate: ok` on the first run. `loopd/tests/broker_port.rs` left alone (old sentence is only test data there). | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user