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>
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:
decidenever returns aDecisionfor anaskgrant. It returns anAsk.redecideis the only thing that turns anAskinto aDecision.
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 itsmod 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
ToolName::parse(&request.tool)isNone→Denied,NoGrant. The arguments are not parsed.args::parse(tool, &request.arguments)fails →Denied,InvalidArguments.- Matching (below). No grant left →
Deniedwith the reason from step M5. - 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 pathpof the grantthe longest such pWriteFile { path, .. }the same, but a pequal topathdoes not countthe longest pthat countsShell { cwd: None }the grant has no paths none Shell { cwd: Some(c) }inside(p, c)for somepthe longest such pHttpFetch { host, .. }args::host_matches(pattern, host)for some patternnone A
shellgrant with paths does not cover a call withoutcwd, and one without paths does not cover a call withcwd. -
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:
classis the highestresult_class,untrustedis true if any says so. -
M5. The winner among those left: the most restrictive mode (
Deny, thenAsk, thenAuto); within it the longest matched path (none counts as length 0); then the lowest id. If none is left:GrantExpiredif M3 remembered expiry, elseTaintTooHighif it remembered taint, elseNoGrant.
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:
//! ```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, thencp 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. Runcargo 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. Thencargo 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 insupport/oracle.rsis the specification; find where your code differs from it. - 5. Prove the doctests have teeth. Make the three fields of
Askpub(andMatchedpub).cargo test -p brokerd --docmust now fail on theAskstruct-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 gateprintsgate: ok.
Stop and report if
- The property test and a table test disagree about one case.
- A test needs
decideto read a file, the clock or the environment.