brokerd: inherit parent taint for a new subagent

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 20:06:04 -07:00
co-authored by ornith-1.5-35b-a3b
parent 79735b21e4
commit 1afa7397dc
3 changed files with 100 additions and 1 deletions
+28 -1
View File
@@ -72,7 +72,9 @@ impl StateStore {
let path = self.path(session); let path = self.path(session);
let text = match fs::read_to_string(&path) { let text = match fs::read_to_string(&path) {
Ok(text) => text, Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()), Err(err) if err.kind() == io::ErrorKind::NotFound => {
return inherit_or_default(self, session);
}
Err(err) => return Err(StateError::Unreadable(path, err.to_string())), Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
}; };
let file = match serde_json::from_str::<StateFile>(&text) { let file = match serde_json::from_str::<StateFile>(&text) {
@@ -140,6 +142,31 @@ impl StateStore {
} }
} }
/// A missing `sub-<parent>-<n>` file copies the parent's taint and untrusted flag (M6 spec 3.4).
fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result<SessionState, StateError> {
let Some(parent) = parent_of_sub(session) else {
return Ok(SessionState::default());
};
let inherited = store.read(&parent)?;
store.raise(
session,
SessionState::default(),
Label {
class: inherited.taint,
untrusted: inherited.untrusted,
},
)
}
fn parent_of_sub(session: &SessionId) -> Option<SessionId> {
let rest = session.as_str().strip_prefix("sub-")?;
let (parent, n) = rest.rsplit_once('-')?;
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
SessionId::new(parent).ok()
}
/// One line on disk: the taint and the untrusted flag. /// One line on disk: the taint and the untrusted flag.
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
+71
View File
@@ -0,0 +1,71 @@
//! A `sub-*` session copies its parent's taint. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::policy::{Label, SessionState};
use brokerd::state::StateStore;
use bxcore::{DataClass, SessionId};
use tmp::TempDir;
fn id(text: &str) -> SessionId {
SessionId::new(text).unwrap()
}
fn store(home: &TempDir) -> StateStore {
StateStore::new(&home.path().join("broker/sessions"))
}
#[test]
fn a_new_subagent_copies_the_parents_untrusted_flag() {
let home = TempDir::new("state-sub");
let store = store(&home);
let parent = id("mm-thread");
store
.raise(
&parent,
SessionState::default(),
Label {
class: DataClass::Private,
untrusted: true,
},
)
.unwrap();
let child = store.read(&id("sub-mm-thread-3")).unwrap();
assert_eq!(
child,
SessionState {
taint: DataClass::Private,
untrusted: true,
}
);
let path = home.path().join("broker/sessions/sub-mm-thread-3.json");
assert!(path.exists());
}
#[test]
fn a_nested_subagent_copies_through_a_missing_middle_child() {
let home = TempDir::new("state-sub-nest");
let store = store(&home);
store
.raise(
&id("mm-thread"),
SessionState::default(),
Label {
class: DataClass::Secret,
untrusted: true,
},
)
.unwrap();
let leaf = store.read(&id("sub-sub-mm-thread-1-2")).unwrap();
assert_eq!(leaf.taint, DataClass::Secret);
assert!(leaf.untrusted);
}
#[test]
fn a_plain_session_still_creates_nothing_on_read() {
let home = TempDir::new("state-plain");
let store = store(&home);
let _ = store.read(&id("chat-1")).unwrap();
assert!(!home.path().join("broker").exists());
}
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| M6a/05-brokerd-state-inherit | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/state_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p brokerd --test state_subagent`) failed as expected: `a_new_subagent_copies_the_parents_untrusted_flag` (child was default private/trusted) and `a_nested_subagent_copies_through_a_missing_middle_child` (leaf was Private not Secret) failed, `a_plain_session_still_creates_nothing_on_read` passed. Applied I1 in state.rs `read`: replaced the `NotFound` early return with `return inherit_or_default(self, session)`. Applied I2 after `impl StateStore` (after `persist`'s and the `impl`'s closing braces, before `/// One line on disk`): added `inherit_or_default` which calls `parent_of_sub`, returns default when `None`, else `store.read(&parent)?` then `store.raise(session, SessionState::default(), Label{class: inherited.taint, untrusted: inherited.untrusted})`, and `parent_of_sub` which strips `sub-`, `rsplit_once('-')`, requires non-empty ASCII-digit suffix and builds `SessionId::new(parent)`. Did not touch `raise` or `persist`. `cargo check -p brokerd` clean, no warning; `wc -l` prints 176 for state.rs, under 500. All suites pass: `state_subagent` 3, `state` 9. `make gate` printed `gate: ok` with 1590 tests (1587 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6a/04-loopd-scheduler-skip | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p loopd --test scheduler_subagent`) failed as expected: both tests failed, `work` not empty (a `Heartbeat` and a `Job { name: "mail", session: "job-mail-20261003t0700" }`) and `requests_to(CHAT)` not empty while the child was held. Applied H1 in channel.rs: added `holds_prefix` after `hold`'s closing `}` inside `impl Context`, locking `busy` like `hold` and returning whether any id starts with the prefix. Applied K2 in scheduler.rs `heartbeat`: after the `if !due { return; }` block and before `SessionId::new("heartbeat")`, `if ctx.holds_prefix("sub-") { return; }`. Applied K1 in scheduler.rs `tick`: immediately after `self.heartbeat(...)` and before `jobs_dir`, `if ctx.holds_prefix("sub-") { return work; }` so leftover-close on the first tick is already done. `cargo check -p loopd` clean, no warning; `wc -l` prints 307 channel.rs, 310 scheduler.rs, both under 500. All suites pass: `scheduler_subagent` 2, `scheduler_heartbeat` 6, `scheduler_cron` 6, `scheduler_heartbeat_failed` 1. `make gate` printed `gate: ok` with 1587 tests (1585 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6a/04-loopd-scheduler-skip | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p loopd --test scheduler_subagent`) failed as expected: both tests failed, `work` not empty (a `Heartbeat` and a `Job { name: "mail", session: "job-mail-20261003t0700" }`) and `requests_to(CHAT)` not empty while the child was held. Applied H1 in channel.rs: added `holds_prefix` after `hold`'s closing `}` inside `impl Context`, locking `busy` like `hold` and returning whether any id starts with the prefix. Applied K2 in scheduler.rs `heartbeat`: after the `if !due { return; }` block and before `SessionId::new("heartbeat")`, `if ctx.holds_prefix("sub-") { return; }`. Applied K1 in scheduler.rs `tick`: immediately after `self.heartbeat(...)` and before `jobs_dir`, `if ctx.holds_prefix("sub-") { return work; }` so leftover-close on the first tick is already done. `cargo check -p loopd` clean, no warning; `wc -l` prints 307 channel.rs, 310 scheduler.rs, both under 500. All suites pass: `scheduler_subagent` 2, `scheduler_heartbeat` 6, `scheduler_cron` 6, `scheduler_heartbeat_failed` 1. `make gate` printed `gate: ok` with 1587 tests (1585 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6a/03-loopd-subagent-spawn | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_spawn.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0560 `Runtime` has no field `ctx`. Added `pub ctx: Option<&'a crate::channel::Context>` to `Runtime` in turn.rs R1, filled it `Some(&*ctx)` in channel.rs C1 (live turn), `Some(ctx)` in the cron and heartbeat literals in scheduler.rs S1/S2 (both already held an immutable borrow of `ctx`), and `None` in the seven test literals: support/turn.rs, compact_turn.rs (the helper and the inline one), session_slot.rs, end_to_end.rs, turn_broker.rs, tool_summary_call.rs. Replaced the stub `run` in subagent.rs with the full spawn: build `sub-<parent>-<n>` from the already-consumed `call_id.0`, overflow returns `denial_message(InvalidArguments, Some(...))` naming subagent-id without holding, then `rt.ctx` `None` and `hold Busy` and block/baseline/create errors each return `failed`, a `Create` `Exists` falls back to `open`, the child runs one `run_turn` plus `finish_turn` on `slots.background`, memory is updated, `untrusted` is read before the hold drops, and the result is `{id} completed\n{summary}` or `{id} failed`, all `Public`. Added `RUNBOOK` and the use lines the body needs. Appended `## subagent-id` to docs/runbook.md via the task's `{ echo; cat ...; }` (grep -c prints 1). `cargo check -p loopd` clean, no warning; `wc -l` prints 185 subagent.rs, 335 turn.rs, 301 channel.rs, 304 scheduler.rs, all under 500. All suites pass: `subagent_spawn` 5, `subagent_dispatch` 3, `subagent_registry` 2, `session_slot` 4, `tool_summary_call` 6, `compact_turn`, `channel`. `make gate` printed `gate: ok` with 1585 tests (1580 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6a/03-loopd-subagent-spawn | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_spawn.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0560 `Runtime` has no field `ctx`. Added `pub ctx: Option<&'a crate::channel::Context>` to `Runtime` in turn.rs R1, filled it `Some(&*ctx)` in channel.rs C1 (live turn), `Some(ctx)` in the cron and heartbeat literals in scheduler.rs S1/S2 (both already held an immutable borrow of `ctx`), and `None` in the seven test literals: support/turn.rs, compact_turn.rs (the helper and the inline one), session_slot.rs, end_to_end.rs, turn_broker.rs, tool_summary_call.rs. Replaced the stub `run` in subagent.rs with the full spawn: build `sub-<parent>-<n>` from the already-consumed `call_id.0`, overflow returns `denial_message(InvalidArguments, Some(...))` naming subagent-id without holding, then `rt.ctx` `None` and `hold Busy` and block/baseline/create errors each return `failed`, a `Create` `Exists` falls back to `open`, the child runs one `run_turn` plus `finish_turn` on `slots.background`, memory is updated, `untrusted` is read before the hold drops, and the result is `{id} completed\n{summary}` or `{id} failed`, all `Public`. Added `RUNBOOK` and the use lines the body needs. Appended `## subagent-id` to docs/runbook.md via the task's `{ echo; cat ...; }` (grep -c prints 1). `cargo check -p loopd` clean, no warning; `wc -l` prints 185 subagent.rs, 335 turn.rs, 301 channel.rs, 304 scheduler.rs, all under 500. All suites pass: `subagent_spawn` 5, `subagent_dispatch` 3, `subagent_registry` 2, `session_slot` 4, `tool_summary_call` 6, `compact_turn`, `channel`. `make gate` printed `gate: ok` with 1585 tests (1580 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M6a/02-loopd-subagent-dispatch | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_dispatch.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0599 no `Subagent` variant and E0425 no `child_id`. Added `Dispatch::Subagent { task }` in tools.rs D1, routed `subagent` in `dispatch` and `dispatch_call_tool` (D2/D3) via `crate::subagent::dispatch_args`, added the `Subagent` arm in `run_call` (turn.rs T1) calling the stub `run`, and appended `child_id`, `parse_task`, `dispatch_args` and the stub `run` to subagent.rs. `parse_task` walks every key (unknown field), requires `task`, demands a string task and a JSON object arguments, and maps errors to `denial_message(InvalidArguments, Some(&detail))`. `run` stays a compiling stub, no spawn, no `Runtime.ctx`. `cargo check -p loopd` clean, no warning; `wc -l` prints 76 for subagent.rs, 477 for tools.rs, 332 for turn.rs, all under 500. All suites pass: `subagent_dispatch` 3, `subagent_registry` 2, `tools` 11. `make gate` printed `gate: ok` with 1580 tests (1577 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6a/02-loopd-subagent-dispatch | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_dispatch.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0599 no `Subagent` variant and E0425 no `child_id`. Added `Dispatch::Subagent { task }` in tools.rs D1, routed `subagent` in `dispatch` and `dispatch_call_tool` (D2/D3) via `crate::subagent::dispatch_args`, added the `Subagent` arm in `run_call` (turn.rs T1) calling the stub `run`, and appended `child_id`, `parse_task`, `dispatch_args` and the stub `run` to subagent.rs. `parse_task` walks every key (unknown field), requires `task`, demands a string task and a JSON object arguments, and maps errors to `denial_message(InvalidArguments, Some(&detail))`. `run` stays a compiling stub, no spawn, no `Runtime.ctx`. `cargo check -p loopd` clean, no warning; `wc -l` prints 76 for subagent.rs, 477 for tools.rs, 332 for turn.rs, all under 500. All suites pass: `subagent_dispatch` 3, `subagent_registry` 2, `tools` 11. `make gate` printed `gate: ok` with 1580 tests (1577 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |