Files
boxmaker/docs/plans/M3a/07-brokerd-policy.md
T
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

174 lines
8.3 KiB
Markdown

# 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
```rust
#[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 `None` → `Denied`, `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: `Deny` → `Denied { DeniedByGrant, Some(id), Some(sha256) }`; `Ask` →
`Ask`; `Auto` → `Allowed`.
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 `Auto` → `Ok(Decision)` built from the `Ask`'s request and
arguments and **the grant that won now**. Winner `Deny` → `Err` 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:
```rust
//! ```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.