Plan M5a: local memory in 15 tasks
memoryd (files, the check, edits, journal, index, recall, serving), the memory tools and built-in grants in brokerd, the memory block in loopd, bxctl reindex and memory, and the device checks with memoryd. Each task was built as a reference and gated. Each was replayed on its own at its end state (854 to 936 tests) and mutation-checked. Each skeleton compiles and fails its tests. Every todo comment was read beside its signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
# M5a task 12: decided memory calls go to `memoryd`, and labels only get stricter
|
||||
|
||||
**Branch:** `m5a` (run `git switch m5a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `brokerd: memory calls to memoryd with the session's state; labels only stricter`
|
||||
|
||||
## Goal
|
||||
|
||||
M5a spec, section 8:
|
||||
|
||||
- A decided `recall` or `memory` call goes to `memoryd`'s `brokerd` socket as a `memory_call`,
|
||||
with the session's taint and untrusted flag **as `brokerd` held them when it decided**. Every
|
||||
other call goes to the configured runtime, as before (`Routed`).
|
||||
- `memoryd` knows the label of what it returns (a `recall` hit from an untrusted session). The
|
||||
result keeps the **stricter** of that and the grant's label: the higher class, and untrusted if
|
||||
either says so. A runtime can never make a result laxer.
|
||||
- The container runtime refuses a memory tool rather than start a container for it.
|
||||
- `memoryd` not answering in 30 seconds is a failed result with a fixed sentence and a runbook
|
||||
pointer.
|
||||
|
||||
To carry the session's state, `Decision` gains a private field (`decide` and `redecide` already
|
||||
receive the state), and `RunSpec` gains one too. `Runtime` gains `run_labelled`, with a default
|
||||
that calls `run` and returns no label, so no existing runtime changes.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy (skeleton): `crates/brokerd/src/memory_port.rs`
|
||||
- Copy (tests): `crates/brokerd/tests/memory_port.rs`, and a **new version** of the given
|
||||
`crates/brokerd/tests/support/rig.rs` (its `Config` literal gains `memory`)
|
||||
- Append: `docs/plans/M5a/files/runbook-memoryd-unreachable.md` to the end of `docs/runbook.md`
|
||||
(a blank line before it)
|
||||
- Modify: `crates/brokerd/src/lib.rs` (`pub mod memory_port;` after `pub mod ledger;`),
|
||||
`crates/brokerd/src/policy/mod.rs`, `crates/brokerd/src/runner.rs`,
|
||||
`crates/brokerd/src/container.rs`, `crates/brokerd/src/config.rs`, `crates/brokerd/src/main.rs`,
|
||||
`docs/implementer-log.md`
|
||||
|
||||
## The skeleton
|
||||
|
||||
Written: `UNREACHABLE`, `MEMORY_WAIT`, `MemoryPort` and `new`, `Routed`, and both `run` methods
|
||||
(they call `run_labelled`). Fill: `MemoryPort::ask`, `MemoryPort::run_labelled`,
|
||||
`Routed::run_labelled`.
|
||||
|
||||
## `policy/mod.rs`, exactly
|
||||
|
||||
1. In `struct Decision`, after `matched: Matched,` add:
|
||||
|
||||
```rust
|
||||
/// The session's state when the call was decided; `memoryd` needs it (M5a spec, section 8).
|
||||
state: SessionState,
|
||||
```
|
||||
|
||||
(Only `Decision`: `Ask` is unchanged.)
|
||||
2. In `impl Decision`, after `request()` add:
|
||||
|
||||
```rust
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
```
|
||||
|
||||
3. In `decide`, the `Mode::Auto => Outcome::Allowed(Decision { request, args, matched, })`
|
||||
literal gains `state,` after `matched,`; in `redecide`, the `Ok(Decision { … matched, })`
|
||||
literal gains `state,` after `matched,`. Both functions already have `state` as a parameter.
|
||||
|
||||
## `runner.rs`, exactly
|
||||
|
||||
1. `use crate::policy::Decision;` becomes `use crate::policy::{Decision, Label, SessionState};`.
|
||||
2. In `struct RunSpec`, after `egress: Option<Vec<String>>,` add `state: SessionState,`; in
|
||||
`impl RunSpec`, after `egress()` add:
|
||||
|
||||
```rust
|
||||
/// The session's state when the call was decided.
|
||||
pub fn state(&self) -> SessionState {
|
||||
self.state
|
||||
}
|
||||
```
|
||||
|
||||
3. In `trait Runtime`, after `fn run(…);` add:
|
||||
|
||||
```rust
|
||||
|
||||
/// `run`, and a label for what came back when the runtime knows one (`memoryd` does: a
|
||||
/// `recall` hit from an untrusted session). The runner keeps the stricter of this and the
|
||||
/// grant's label. Container runtimes know none.
|
||||
fn run_labelled(&self, spec: &RunSpec) -> Result<(RunOutput, Option<Label>), RunError> {
|
||||
self.run(spec).map(|output| (output, None))
|
||||
}
|
||||
```
|
||||
|
||||
4. In `run`, the `RunSpec { … }` literal gains `state: decision.state(),` after `egress,`, and
|
||||
|
||||
```rust
|
||||
match runtime.run(&spec) {
|
||||
Ok(output) => ToolResponse::Result {
|
||||
content: output.content,
|
||||
class: label.class,
|
||||
untrusted: label.untrusted,
|
||||
truncated: output.truncated,
|
||||
},
|
||||
```
|
||||
|
||||
becomes
|
||||
|
||||
```rust
|
||||
match runtime.run_labelled(&spec) {
|
||||
Ok((output, found)) => {
|
||||
// Labels only get stricter: the higher class, and untrusted if either says so.
|
||||
let (class, untrusted) = match found {
|
||||
Some(found) => (
|
||||
label.class.max(found.class),
|
||||
label.untrusted || found.untrusted,
|
||||
),
|
||||
None => (label.class, label.untrusted),
|
||||
};
|
||||
ToolResponse::Result {
|
||||
content: output.content,
|
||||
class,
|
||||
untrusted,
|
||||
truncated: output.truncated,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(the `Err` arm below it is unchanged).
|
||||
|
||||
## `container.rs`, `config.rs`, `main.rs`, exactly
|
||||
|
||||
- `container.rs`: after `pub type Log = …;` add
|
||||
|
||||
```rust
|
||||
|
||||
/// A memory tool that reached the container runtime: fixed, it reaches the model.
|
||||
pub const IN_MEMORYD: &str = "the memory tools run in memoryd, not in a container";
|
||||
```
|
||||
|
||||
and at the top of `Podman`'s `fn run`, before `let n = self.next.fetch_add(…)`, add
|
||||
|
||||
```rust
|
||||
// `Routed` sends the memory tools to `memoryd`; one here is refused, never run.
|
||||
if spec.tool().is_memory() {
|
||||
return Err(RunError::Unavailable(IN_MEMORYD.to_string()));
|
||||
}
|
||||
```
|
||||
|
||||
- `config.rs`: before `fn default_podman()` add
|
||||
|
||||
```rust
|
||||
/// `[memory]`: where `memoryd` takes the decided `recall` and `memory` calls. The memory tools are
|
||||
/// always on, so the section is optional; an empty path means the default under `home`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Memory {
|
||||
pub socket: PathBuf,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
in `struct Config`, after the `gateway` field, add `#[serde(default)]` and
|
||||
`pub memory: Memory,`; and before `pub fn audit_dir` add
|
||||
|
||||
```rust
|
||||
/// `memoryd`'s socket for `brokerd`.
|
||||
pub fn memory_socket(&self) -> PathBuf {
|
||||
if self.memory.socket.as_os_str().is_empty() {
|
||||
self.paths.home.join("run/broker-memory/memory.sock")
|
||||
} else {
|
||||
self.memory.socket.clone()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `main.rs`: after `use brokerd::container::Podman;` add
|
||||
`use brokerd::memory_port::{MEMORY_WAIT, MemoryPort, Routed};`, and just before
|
||||
`let started = match serve::start(cfg, accept_break, runtime, log) {` add
|
||||
|
||||
```rust
|
||||
// The memory tools go to `memoryd`, whatever runs the others (M5a spec, section 8).
|
||||
let runtime: Box<dyn Runtime> = Box::new(Routed {
|
||||
memory: MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT),
|
||||
other: runtime,
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy** the skeleton, the test and the new `rig.rs`; append the runbook entry; make the
|
||||
edits, one file at a time, with `cargo check -p brokerd` after each.
|
||||
- [ ] **2. See it fail.** `cargo test -p brokerd --test memory_port`: 4 failed, 1 passed (the one
|
||||
that never reaches `memoryd`).
|
||||
- [ ] **3. Fill** the three functions. `cargo fmt --all`.
|
||||
- [ ] **4. See it pass.** The same command, three times: 5 passed. Then `cargo test -p brokerd`:
|
||||
every suite passes.
|
||||
- [ ] **5. Run the gate.** `make gate`: `gate: ok`, 5 more tests than before (about 929).
|
||||
- [ ] **6. Log and commit.** `git add -A crates/brokerd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
## Done when
|
||||
|
||||
- `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A `policy` compile-fail doc test starts to compile, or any other `brokerd` test fails.
|
||||
Reference in New Issue
Block a user