diff --git a/crates/brokerd/src/ledger/answer.rs b/crates/brokerd/src/ledger/answer.rs index 3a45949..99d9257 100644 --- a/crates/brokerd/src/ledger/answer.rs +++ b/crates/brokerd/src/ledger/answer.rs @@ -6,7 +6,7 @@ use proto::{ApprovalAnswer, AuditEvent, DataClass, DenyReason, Timestamp, ToolRe use super::{Answer, Answered, Call, Grants, Inner, Ledger}; use crate::approvals::Verdict; -use crate::policy::{Ask, Denial, SessionState}; +use crate::policy::{Ask, Denial, Label, SessionState}; impl Ledger { /// Re-decide an approval and record it. @@ -159,10 +159,17 @@ impl Ledger { }; match &response { - // 3. A result raises the state, then is recorded. - ToolResponse::Result { .. } => { + // 3. A result raises the state, then is recorded. The state follows the stricter of + // the grant's label and the result's own: `memoryd` can make a result stricter. + ToolResponse::Result { + class, untrusted, .. + } => { + let label = Label { + class: call.label.class.max(*class), + untrusted: call.label.untrusted || *untrusted, + }; let raised = match self.state_read(&guard, &call.session) { - Ok(state) => match guard.state.raise(&call.session, state, call.label) { + Ok(state) => match guard.state.raise(&call.session, state, label) { Ok(raised) => raised, Err(e) => { (self.log)(&format!("brokerd: {e}")); diff --git a/crates/brokerd/tests/ledger_label.rs b/crates/brokerd/tests/ledger_label.rs new file mode 100644 index 0000000..28f7932 --- /dev/null +++ b/crates/brokerd/tests/ledger_label.rs @@ -0,0 +1,123 @@ +//! The ledger raises a session's state by the stricter of the grant's label and the result's own +//! (M5a spec, section 8). `memoryd` can make a `recall` result untrusted; if the session did not +//! follow, a clean session could recall a tainted note and then write it into `memory.md`. Do not +//! edit. + +#[path = "support/build.rs"] +mod build; +#[path = "support/rig.rs"] +mod rig; +#[path = "support/sink.rs"] +mod sink; +#[path = "support/tmp.rs"] +mod tmp; + +use brokerd::ledger::Call; +use brokerd::policy::{Label, SessionState}; +use build::now; +use proto::{AuditEvent, CallId, DataClass, SessionId, ToolResponse}; +use rig::Rig; + +fn label(class: DataClass, untrusted: bool) -> Label { + Label { class, untrusted } +} + +fn call(label: Label) -> Call { + Call { + session: SessionId::new("s1").unwrap(), + call: CallId(1), + decision: 0, + label, + } +} + +fn result(label: Label) -> ToolResponse { + ToolResponse::Result { + content: "[1] memory-tainted ยท note".to_string(), + class: label.class, + untrusted: label.untrusted, + truncated: false, + } +} + +fn state(rig: &Rig) -> SessionState { + rig.state().read(&SessionId::new("s1").unwrap()).unwrap() +} + +/// The one result record's (class, untrusted, taint_after). +fn recorded(rig: &Rig) -> (DataClass, bool, DataClass) { + match rig.events().as_slice() { + [ + AuditEvent::Result { + class, + untrusted, + taint_after, + .. + }, + ] => (*class, *untrusted, *taint_after), + other => panic!("{other:?}"), + } +} + +#[test] +fn a_recall_of_a_tainted_note_makes_a_clean_session_untrusted() { + let rig = Rig::new("label-recall"); + let ledger = rig.ledger(); + // `recall`'s built-in grant labels its results private and trusted; `memoryd` found a + // `memory-tainted` hit and said untrusted. + let found = label(DataClass::Private, true); + let answer = ledger.finish( + &call(label(DataClass::Private, false)), + result(found), + now(), + ); + assert_eq!(answer, result(found), "passed on unchanged"); + assert_eq!( + state(&rig), + SessionState { + taint: DataClass::Private, + untrusted: true + } + ); + assert_eq!( + recorded(&rig), + (DataClass::Private, true, DataClass::Private) + ); +} + +#[test] +fn a_result_of_a_higher_class_than_its_grant_raises_the_taint() { + let rig = Rig::new("label-class"); + let ledger = rig.ledger(); + let found = label(DataClass::Secret, false); + ledger.finish(&call(label(DataClass::Public, false)), result(found), now()); + assert_eq!( + state(&rig), + SessionState { + taint: DataClass::Secret, + untrusted: false + } + ); + assert_eq!( + recorded(&rig), + (DataClass::Secret, false, DataClass::Secret) + ); +} + +#[test] +fn a_result_laxer_than_its_grant_still_raises_the_state_by_the_grant() { + let rig = Rig::new("label-laxer"); + let ledger = rig.ledger(); + ledger.finish( + &call(label(DataClass::Secret, true)), + result(label(DataClass::Public, false)), + now(), + ); + assert_eq!( + state(&rig), + SessionState { + taint: DataClass::Secret, + untrusted: true + } + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index a4bdb95..1000158 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5a/21-session-follows-result | 2026-09-25 | done (by the design model) | many | fail | The session added and then deleted diagnostic test files (`crates/bxctl/tests/zz_diag.rs`, `zz_diag2.rs`) and ran about 80 minutes of experiments instead of stopping when a test outside the task failed, as the task says. | The session's edits match the task file exactly and the copied `ledger_label.rs` is identical to the plan's; step 2 showed the two named tests failing, and after the edits `ledger_label`, `cargo test -p brokerd` and `-p bxctl` passed. The gate then failed in `bxctl`'s `args_os` with `NotFound`. The cause was the design model's: its reference worktree for tasks 21 and 22 shared this `target/` through a symlink, so test binaries built there had the worktree's absolute paths compiled in (`CARGO_BIN_EXE_*`, `CARGO_MANIFEST_DIR`), and Cargo, whose freshness check does not include the workspace's absolute path, kept them after the worktree was deleted. After `cargo clean`, `make gate` printed `gate: ok`, 944 tests. The owner stopped the run. | Ornith-1.5-35B-A3B (the session); design model (the commit) | | M5a/20-review-ids | 2026-09-25 | done | 1 | pass | none | The two edits exactly as the task gives, both in `submit` in `crates/memoryd/src/review.rs`: the comment block "how many Waiting lines have an id starting format!(\"p-{day}-\"). The id is format!(\"p-{day}-{:04}\", taken + 1)." became "how many Waiting lines of `lines(store)?`, done or not, have an id starting format!(\"p-{day}-\"): an id is never used twice. The id is format!(\"p-{day}-{:04}\", taken + 1)."; and `let taken = waiting(store)?` became `let taken = lines(store)?`, the `.iter()`/`.filter(..)`/`.count()` after it unchanged. `waiting` untouched, since `list`/`accept`/`reject` still use it. Copied `crates/memoryd/tests/review_ids.rs` from `docs/plans/M5a/files/`. `cargo fmt --all`; step 2 showed exactly the two named tests failing (`a_write_staged_after_an_accepted_one_gets_a_new_id_and_can_be_accepted` expected `p-2026-09-24-0002` got `p-2026-09-24-0001`; `ids_count_every_write_staged_that_day_waiting_or_done` expected `p-2026-09-24-0004` got `p-2026-09-24-0002`); after the edits both pass and `cargo test -p memoryd` every suite passes; `make gate` printed `gate: ok`. `cargo test --workspace` reports 940 passed, plus the gate's separate loopd `end_to_end` ignored test = 941 total. No new dependency, so `Cargo.lock` is unchanged. | ? | | M5a/19-no-forged-headers | 2026-09-25 | done | 2 | timed out (shell 2 min) | none | Two edits exactly as the task gives. `crates/proto/src/memory.rs`: the doc comment above `pub fn entry_problem` (the two lines "holding a line that is only the separator") became the three lines explaining a line starting with the separator, spaces aside, would read as a header in notes.md and a header says where an entry came from; and in the body, `if line.trim() == SEPARATOR { return Some(format!("an entry cannot hold a line that is only {SEPARATOR}")); }` became `if line.trim_start().starts_with(SEPARATOR) { return Some(format!("an entry cannot hold a line that starts with {SEPARATOR}")); }`, the for loop and closing braces unchanged. `crates/memoryd/src/notes.rs` `parse_header`: the `untrusted` match `Some(s) => *s == "untrusted"` became `Some(&"untrusted") => true, Some(_) => return None`, so a `noted` header whose last part is anything other than `untrusted` is not a header. Copied three test files from `docs/plans/M5a/files/`: `crates/proto/tests/memory_args.rs` and `crates/memoryd/tests/notes.rs` (new versions) and the new `crates/memoryd/tests/forgery.rs` (not the `-task02`/`-task06` files). `cargo fmt --all`. Step 2 showed exactly the three named tests failing (`entries_are_not_empty_not_too_long_and_hold_no_separator_line`, `a_noted_header_with_an_unknown_last_part_is_not_a_header`, `a_note_holding_a_header_line_is_refused_and_nothing_is_written`); the fourth, `recall_finds_no_identity_in_what_an_untrusted_session_wrote`, already passed. After the edits the step-2 command passed and `cargo test -p proto -p memoryd -p brokerd` every suite passed. `make gate` printed `gate: ok`; `cargo test --workspace` reported 938 passed and the gate's separate loopd `end_to_end` ignored test added 1, for 939 total. First `make gate` run hit the shell's 120 s timeout before finishing (the loopd daemon test needs more than 2 min); the second run completed with `gate: ok`. No new dependency, so `Cargo.lock` is unchanged. | Ornith-1.5-35B-A3B | | M5a/18-repeated-code | 2026-09-25 | done | 2 | pass | none | The four edits exactly as the task gives: `edit.rs` added the private `claim(file, before, &mut named, old)` one blank line above `consolidate`, and replaced the four identical `Keep`/`Rewrite`/`Move`/`Drop` blocks (the `position` check plus the `named.insert` duplicate guard) with `claim(file, before, &mut named, &old)?;`, leaving the `New` arm untouched; `proto/src/memory.rs` added the private `entry(text, max)` one blank line above `impl RecallArgs`, and rewrote `MemoryArgs::check` to call `entry` with `?` instead of the nine `if let Some(problem) = entry_problem(..) { return Err(problem); }` blocks (the `Consolidate` arm's `map_err(|problem| problem.to_string())` became `map_err(str::to_string)`), `RecallArgs::check` unchanged; `brokerd/src/builtin.rs` replaced the two `GrantProblem {..}` inline closures in `grants` with a single `let problem = |error: String| GrantProblem {..}` and `map_err(|e| problem(e.to_string()))`; `bxctl/src/memory.rs` added `let unreachable = |e: &dyn std::fmt::Display| format!("cannot reach memoryd at {}: {e}", socket.display())` and used it for connect, the two `set_*_timeout` sets and the `write_frame` (the `memoryd did not answer` line was left as its own sentence). `cargo fmt --all`; the step-2 greps print 1, 0, 2, 1 in order; `cargo test -p memoryd -p proto -p brokerd -p bxctl` every suite passes; `make gate` printed `gate: ok` with 936 tests, unchanged. No new dependency, so `Cargo.lock` is unchanged. | Ornith-1.5-35B-A3B |