Files
boxmaker/docs/plans/M3a/07-brokerd-policy.md
kyleandClaude Opus 5 e3f37da232 Hand over the M3a plan: 22 tasks, their files, and the check record
Task files, the files they copy in (byte-identical to the reference on
m3a-ref), each area's check record, and a README with the per-task
table of what each check exposed. The handoff note is done with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:43 -07:00

8.3 KiB

M3a task 07: the policy functions

Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop) Commit subject: Decide tool calls against grants, taint and time

Goal

Replace the M1 stub in crates/brokerd/src/policy.rs with the real thing: decide and redecide. The module does no I/O and reads no clock; the grants, the session's state and the time are arguments. It is the only place a Decision or an Ask is built.

Two facts the types must carry, not the callers' good behaviour:

  • decide never returns a Decision for an ask grant. It returns an Ask.
  • redecide is the only thing that turns an Ask into a Decision.

Files

  • Copy: crates/brokerd/tests/policy.rs, policy_matching.rs, policy_redecide.rs, policy_property.rs, crates/brokerd/tests/support/build.rs, support/oracle.rs
  • Modify: crates/brokerd/src/policy.rs (rewrite; delete its mod tests), docs/implementer-log.md
  • Do not touch crates/brokerd/src/runner.rs: run(decision: Decision) still compiles.

Interfaces

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionState { pub taint: DataClass, pub untrusted: bool }
impl Default for SessionState { /* taint: Private, untrusted: false */ }

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Label { pub class: DataClass, pub untrusted: bool }

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Denial {
    pub reason: DenyReason,
    pub grant: Option<String>,          // Some only for DeniedByGrant: the deny grant's id
    pub grant_sha256: Option<Hash32>,   // Some only for DeniedByGrant
}
impl Denial { pub fn new(reason: DenyReason) -> Denial; }   // both options None

#[derive(Debug)] struct Matched {       // private
    grant: String, grant_sha256: Hash32, matched_path: Option<String>,
    paths: Vec<String>, hosts: Vec<String>, expires: Option<Timestamp>, label: Label,
}
#[derive(Debug)] pub struct Decision { request: ToolRequest, args: ToolArgs, matched: Matched }
#[derive(Debug)] pub struct Ask      { request: ToolRequest, args: ToolArgs, matched: Matched }

#[derive(Debug)]
pub enum Outcome { Allowed(Decision), Ask(Ask), Denied(Denial) }

pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp) -> Outcome;
pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp) -> Result<Decision, Denial>;

Decision and Ask: fields private, exactly these three names, only #[derive(Debug)]. No Clone, no Serialize, no Deserialize, no new. Both have the same nine getters: request() -> &ToolRequest, args() -> &ToolArgs, grant() -> &str, grant_sha256() -> Hash32, matched_path() -> Option<&str>, paths() -> &[String], hosts() -> &[String] (all the winning grant's paths and hosts; the runner mounts them), expires() -> Option<Timestamp>, label() -> Label.

decide, in this order; the first that applies is the answer

  1. ToolName::parse(&request.tool) is NoneDenied, NoGrant. The arguments are not parsed.
  2. args::parse(tool, &request.arguments) fails → Denied, InvalidArguments.
  3. Matching (below). No grant left → Denied with the reason from step M5.
  4. The winner's mode: DenyDenied { DeniedByGrant, Some(id), Some(sha256) }; AskAsk; AutoAllowed.

For every reason except DeniedByGrant, grant and grant_sha256 are None.

Matching

grants.grants() is in id order. For each grant:

  • M1. Skip it unless grant.tool == tool.as_str().

  • M2. Does it cover the arguments? If not, skip it; it plays no further part.

    Arguments Covered when Matched path
    ReadFile { path } args::inside(p, path) for some path p of the grant the longest such p
    WriteFile { path, .. } the same, but a p equal to path does not count the longest p that counts
    Shell { cwd: None } the grant has no paths none
    Shell { cwd: Some(c) } inside(p, c) for some p the longest such p
    HttpFetch { host, .. } args::host_matches(pattern, host) for some pattern none

    A shell grant with paths does not cover a call without cwd, and one without paths does not cover a call with cwd.

  • M3. expired = expires.is_some_and(|at| now >= at) (expired at the instant, not after). too_tainted = state.taint > grant.max_taint. Neither → the grant is left. Only expired → remember "some grant was ruled out only by expiry". Only too tainted → remember the same for taint. Both → remember nothing.

  • M4. The label, over every grant left, not the winner alone: class is the highest result_class, untrusted is true if any says so.

  • M5. The winner among those left: the most restrictive mode (Deny, then Ask, then Auto); within it the longest matched path (none counts as length 0); then the lowest id. If none is left: GrantExpired if M3 remembered expiry, else TaintTooHigh if it remembered taint, else NoGrant.

state.untrusted is not an input to matching.

redecide

Run matching again with ask's own arguments (ask.args.tool() gives the tool) against the grants and state given now. Winner Ask or AutoOk(Decision) built from the Ask's request and arguments and the grant that won now. Winner DenyErr with DeniedByGrant and the id. None left → Err with the M5 reason.

The doctests

Keep a module doc comment with seven examples; they are how the gate proves the two facts above. Six are compile_fail, one for each of Decision and Ask in each of three kinds, and one compiles:

//! ```compile_fail
//! let request = proto::ToolRequest {
//!     session: proto::SessionId::new("s1").unwrap(),
//!     call: proto::CallId(1),
//!     tool: "shell".to_string(),
//!     arguments: r#"{"command":"ls"}"#.to_string(),
//! };
//! let args = brokerd::args::parse(brokerd::args::ToolName::Shell, &request.arguments).unwrap();
//! let _ = brokerd::policy::Decision { request, args, matched: todo!() };
//! ```
//!
//! ```compile_fail
//! fn needs_clone<T: Clone>() {}
//! needs_clone::<brokerd::policy::Decision>();
//! ```
//!
//! ```compile_fail
//! fn needs_decoding<T: serde::de::DeserializeOwned>() {}
//! needs_decoding::<brokerd::policy::Decision>();
//! ```

Write the same three again with Ask in place of Decision. The seventh has the same request and args setup, calls needs_clone::<proto::ToolRequest>() and needs_decoding::<proto::ToolRequest>(), then calls decide(request, &GrantSet::default(), SessionState::default(), now) and asserts the outcome is Denied with NoGrant. It shows the other six fail because of Decision and Ask, not because of a mistake in the example.

Steps

  • 1. Copy. git switch m3a, then cp docs/plans/M3a/files/crates/brokerd/tests/policy*.rs crates/brokerd/tests/ && cp docs/plans/M3a/files/crates/brokerd/tests/support/build.rs docs/plans/M3a/files/crates/brokerd/tests/support/oracle.rs crates/brokerd/tests/support/
  • 2. See the tests fail. cargo test -p brokerd --test policy. Expected: it does not compile.
  • 3. Rewrite policy.rs. Run cargo fmt --all.
  • 4. See the tests pass. cargo test -p brokerd --test policy --test policy_matching --test policy_redecide --test policy_property. Expected: 7 passed, 10 passed, 7 passed, 4 passed. Then cargo test -p brokerd --doc: 7 doctests pass. If the property test fails, its message names a seed and a case and prints the grants: the oracle in support/oracle.rs is the specification; find where your code differs from it.
  • 5. Prove the doctests have teeth. Make the three fields of Ask pub (and Matched pub). cargo test -p brokerd --doc must now fail on the Ask struct-literal example. Change it back, run again, all pass. Say in the log that you did this.
  • 6. Run the gate. make gate. Expected last line: gate: ok.
  • 7. Log and commit. git add crates/brokerd docs/implementer-log.md && git commit

Done when

  • The four test files and the seven doctests pass; step 5 was done; make gate prints gate: ok.

Stop and report if

  • The property test and a table test disagree about one case.
  • A test needs decide to read a file, the clock or the environment.