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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user