# 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, // Some only for DeniedByGrant: the deny grant's id pub grant_sha256: Option, // 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, paths: Vec, hosts: Vec, expires: Option, 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` 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`, `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() {} //! needs_clone::(); //! ``` //! //! ```compile_fail //! fn needs_decoding() {} //! needs_decoding::(); //! ``` ``` Write the same three again with `Ask` in place of `Decision`. The seventh has the same `request` and `args` setup, calls `needs_clone::()` and `needs_decoding::()`, 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.