287 lines
15 KiB
Markdown
287 lines
15 KiB
Markdown
# Pre-M1 design: threat model, data classes, IPC, shared types, workflow
|
|
|
|
Status: draft for owner review, 2026-09-17. It fills the gaps in `docs/design.md` that M1 depends
|
|
on. Where this document and the brief disagree, the brief wins and this document is wrong.
|
|
Decisions behind it are recorded in `docs/decisions.md`.
|
|
|
|
## 1. Threat model
|
|
|
|
**Main adversary: injected text.** Any content the model reads can try to steer it: web pages,
|
|
files, tool output, recalled notes. The model is treated as possibly hostile at every tool call.
|
|
|
|
**Secondary: one compromised component.** Any single role process or tool container may be
|
|
compromised. The goal is containment, not prevention:
|
|
|
|
- Grants are the ceiling. No grant means deny, and no component can write grants.
|
|
- `brokerd` trusts nothing `loopd` reports except the request itself. It computes session taint
|
|
and the untrusted flag from the tool results it has returned.
|
|
- Tool containers never label their own output.
|
|
|
|
**Trusted:** the owner, the host's kernel and container runtime, the owner's Mattermost server and
|
|
the tailnet. `gatewayd` only ever receives content the owner wrote.
|
|
|
|
**Out of scope for v0:**
|
|
|
|
- A compromised Mattermost server. It presumes the whole machine is compromised. Forged approvals
|
|
are therefore out of scope.
|
|
- Accidental secret or PII leaks into Mattermost, for example in an approval request. Accepted.
|
|
- Audit integrity against a compromised `brokerd` (anchoring the chain head elsewhere). Deferred.
|
|
|
|
## 2. Data classes and provenance
|
|
|
|
Two independent properties are tracked for every session. `brokerd` tracks both.
|
|
|
|
**Taint** (what may leave the host). `DataClass` is ordered `public < private < secret`.
|
|
|
|
1. Every session starts at `private`, because `memory/core.md` and the owner's messages are in it.
|
|
2. A tool result labelled `secret` raises the session to `secret`. Taint never goes down.
|
|
3. `public` is a provenance label on results and notes. It never lowers a session.
|
|
4. The grant that authorises a call names the class of its results (`result_class`, default
|
|
`private`).
|
|
5. A grant's `max_taint` is the highest session taint under which the grant applies. A session at
|
|
`secret` cannot use a grant whose `max_taint` is `private`. This one rule covers `consult`
|
|
payloads and `http_fetch` URLs alike.
|
|
6. Unattended egress is controlled by grant mode (`auto` or `ask`), never by lowering the floor.
|
|
Grants scoped to one scheduled job are left to M5.
|
|
|
|
**Untrusted** (what may have steered the model). A boolean per session, false at the start.
|
|
|
|
1. Each grant has `untrusted`, default `true`. The owner sets it to `false` only for sources that
|
|
only the owner writes, such as their own notes directory.
|
|
2. A result from a grant with `untrusted = true` sets the session's flag. It never clears.
|
|
3. M5 uses the flag to mark notes written by such sessions.
|
|
|
|
## 3. Approvals
|
|
|
|
1. `brokerd` composes the approval request: tool, full arguments, matched grant, session taint,
|
|
expiry. Arguments are shown as quoted data, never as prose.
|
|
2. Over Mattermost (M4), only a reply or reaction from an allowlisted user ID on that approval post
|
|
counts. `gatewayd` routes it to `brokerd`. It never reaches `loopd` as a user message.
|
|
3. `bxctl` can approve locally through `broker.sock` (M3). This path stays after M4.
|
|
4. The audit record stores who approved and, for Mattermost, the post ID.
|
|
|
|
## 4. IPC
|
|
|
|
**Transport.** Unix stream sockets. Blocking I/O with threads. No async runtime.
|
|
|
|
**Frame.** A 4-byte big-endian unsigned length `N`, then `N` bytes of UTF-8 JSON holding one
|
|
envelope. `MAX_FRAME` is 1,048,576. A reader that sees `N == 0` or `N > MAX_FRAME` returns an error
|
|
without reading or allocating the body, and the connection is closed.
|
|
|
|
**Envelope.**
|
|
|
|
```json
|
|
{"v":1,"id":7,"final":true,"msg":{"kind":"tool_request","body":{}}}
|
|
```
|
|
|
|
- `v` is the protocol version, 1. A reader that sees another value replies with an `error` message
|
|
and closes.
|
|
- `id` is chosen by the requester. Every frame of the response carries the same `id`.
|
|
- `final` is always present. A response may span several frames; only the last has `final: true`.
|
|
Requests are single frames with `final: true`.
|
|
- `msg` is an adjacently tagged enum. M1 defines `tool_request`, `tool_response` and `error`. Later
|
|
milestones add kinds.
|
|
|
|
**Strictness.** Unknown fields are rejected everywhere (`deny_unknown_fields`). Enums are tagged
|
|
with explicit strings. No floats. No free-form maps. Byte-exact fixture files under
|
|
`crates/proto/tests/fixtures/` are the wire specification.
|
|
|
|
**Sockets.** Each daemon listens on one socket named after itself.
|
|
|
|
| Socket | Listener | Clients | Carries |
|
|
|---|---|---|---|
|
|
| `infer.sock` | `inferproxy` | `loopd` | Plain HTTP, forwarded byte for byte. Not framed. |
|
|
| `broker.sock` | `brokerd` | `loopd`, `bxctl` | Tool requests; local approvals and admin |
|
|
| `loop.sock` | `loopd` | `bxctl chat` (M2), `gatewayd` (M4) | The channel protocol, the same for both |
|
|
| `gateway.sock` | `gatewayd` | `brokerd` | Approval requests and answers |
|
|
|
|
**Peer authentication** is by file permissions. Each pair of roles has its own directory under
|
|
`run/` that only those two can enter. In M7 each becomes a shared volume. No tokens or handshakes.
|
|
|
|
## 5. Shared types in `proto`
|
|
|
|
`proto` holds data types and the frame codec. It holds no policy and performs no I/O except
|
|
reading and writing frames on a caller-supplied stream. All types derive `Debug, Clone, PartialEq,
|
|
Eq, Serialize, Deserialize` and reject unknown fields.
|
|
|
|
```rust
|
|
// Identifiers. Constructors and Deserialize validate; invalid input is an error, never a panic.
|
|
pub struct SessionId(String); // 1 to 64 chars of [a-z0-9-]; used as a directory name
|
|
pub struct Epoch(pub u32);
|
|
pub struct CallId(pub u64); // assigned by loopd, increasing within a session
|
|
pub struct Hash32([u8; 32]); // JSON: 64 lowercase hex chars
|
|
pub struct Timestamp(u64); // Unix milliseconds; JSON: "2026-09-17T08:05:00.000Z"
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum DataClass { Public, Private, Secret } // derives PartialOrd, Ord in this order
|
|
|
|
// IPC
|
|
pub struct Envelope { pub v: u32, pub id: u64, pub r#final: bool, pub msg: Message }
|
|
#[serde(tag = "kind", content = "body", rename_all = "snake_case")]
|
|
pub enum Message { ToolRequest(ToolRequest), ToolResponse(ToolResponse), Error(WireError) }
|
|
pub struct WireError { pub code: ErrorCode, pub detail: String }
|
|
pub enum ErrorCode { BadFrame, BadVersion, BadMessage, Internal } // snake_case strings
|
|
|
|
pub struct ToolRequest {
|
|
pub session: SessionId, pub call: CallId, pub tool: String,
|
|
pub arguments: String, // the model's JSON object, carried unparsed
|
|
}
|
|
#[serde(tag = "status", rename_all = "snake_case")]
|
|
pub enum ToolResponse {
|
|
PendingApproval { approval: String, expires: Timestamp }, // never final
|
|
Result { content: String, class: DataClass, untrusted: bool, truncated: bool },
|
|
Failed { message: String }, // the tool ran and failed; not a policy decision
|
|
Denied { reason: DenyReason },
|
|
}
|
|
pub enum DenyReason { NoGrant, GrantExpired, TaintTooHigh, DeniedByGrant,
|
|
ApprovalRefused, ApprovalExpired } // snake_case strings
|
|
|
|
// Grants: one grant per file, grants/<id>.toml. The id is the file stem.
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Mode { Auto, Ask, Deny }
|
|
pub struct Grant {
|
|
pub tool: String,
|
|
pub mode: Mode,
|
|
pub max_taint: DataClass,
|
|
pub result_class: DataClass, // default private
|
|
pub untrusted: bool, // default true
|
|
pub expires: Option<Timestamp>, // absent means no expiry
|
|
pub secret: Option<String>, // name of one secret to inject
|
|
pub constraints: Constraints, // default empty
|
|
}
|
|
pub struct Constraints { pub paths: Vec<String>, pub hosts: Vec<String>, pub patterns: Vec<String> }
|
|
```
|
|
|
|
`Grant` is in `proto` so that `bxctl` can check grant files with the parser `brokerd` uses.
|
|
Matching semantics for `Constraints` are specified in M3 and implemented only in `brokerd`.
|
|
|
|
```rust
|
|
// Audit
|
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
|
pub enum DecisionRecord {
|
|
Allowed { grant: String },
|
|
Approved { grant: String, approver: String, post: Option<String> },
|
|
Denied { reason: DenyReason, grant: Option<String> },
|
|
}
|
|
pub struct AuditRecord {
|
|
pub seq: u64, pub time: Timestamp, pub prev: Hash32,
|
|
pub session: SessionId, pub call: CallId, pub tool: String,
|
|
pub arguments: String, // in full
|
|
pub session_taint: DataClass, pub decision: DecisionRecord,
|
|
}
|
|
|
|
// Session log
|
|
pub struct ToolCall { pub id: String, pub name: String, pub arguments: String }
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum LogRecord {
|
|
SessionStart { time: Timestamp, session: SessionId, epoch: Epoch, slot: u32, baseline: Hash32 },
|
|
User { time: Timestamp, content: String },
|
|
Assistant { time: Timestamp, content: Option<String>, reasoning_content: Option<String>,
|
|
tool_calls: Vec<ToolCall> },
|
|
ToolResult { time: Timestamp, call: CallId, tool_call_id: String, content: String,
|
|
class: DataClass, untrusted: bool, truncated: bool },
|
|
CacheLoss { time: Timestamp, expected: u64, got: u64 },
|
|
EpochEnd { time: Timestamp, next: Epoch, summary: String },
|
|
}
|
|
```
|
|
|
|
`Assistant` stores the fields the chat template reads, exactly as the server returned them. M0
|
|
replayed these fields and got full cache hits.
|
|
|
|
**`Decision` is not in `proto`.** It is defined in `brokerd::policy` with a private field, and it
|
|
implements neither `Deserialize` nor `Clone`. Only `brokerd::policy` can construct one, and the
|
|
container runner takes it by value. A `compile_fail` doctest in `brokerd` proves that code outside
|
|
the module cannot build one. For doctests to run, every role crate is a library with a thin
|
|
`main.rs`.
|
|
|
|
## 6. Log files
|
|
|
|
Both logs are JSONL written by serde from the types above, through one writer module per log.
|
|
|
|
**Audit log, `audit/YYYY-MM-DD.jsonl`.**
|
|
|
|
1. One `AuditRecord` per line, for every decision including denials.
|
|
2. `hash(record)` is SHA-256 over the exact bytes of the stored line, without the trailing newline.
|
|
A verifier hashes stored bytes. It never parses and re-serialises.
|
|
3. `prev` is the hash of the previous record. The first record ever written has 32 zero bytes. The
|
|
first record of a new file continues from the last record of the previous file.
|
|
4. `brokerd` is the only writer. It holds a file lock and calls `fsync` after each record.
|
|
5. On start, a torn final line is kept, a recovery record is appended after it, and the chain
|
|
continues from the last complete record. Nothing is truncated silently.
|
|
|
|
The writer, the hash and the recovery record type are built in M3.
|
|
|
|
**Session log, `sessions/<id>/<epoch>.jsonl`.** One `LogRecord` per line, append only. Built in M2.
|
|
|
|
## 7. Runtime layout
|
|
|
|
- `BOXMAKER_HOME`, default `/var/lib/boxmaker`: `sessions/`, `memory/`, `audit/`, `index/`, and
|
|
`run/` with one socket directory per pair of roles.
|
|
- `/etc/boxmaker/config.toml` and `/etc/boxmaker/grants/*.toml`.
|
|
- In development everything points at `.state/` in the repo, which is git-ignored.
|
|
- M1 documents this. M7 adds the NixOS module that creates users and directories.
|
|
|
|
## 8. Workspace and gate (M1)
|
|
|
|
- Crates live in `crates/`: `proto`, `loopd`, `brokerd`, `gatewayd`, `inferproxy`, `toolkit`,
|
|
`bxctl`. Role crates depend only on `proto`. Edition 2024, `rust-version = "1.95"`,
|
|
`publish = false`, `unsafe_code = "forbid"` as a workspace lint.
|
|
- Code is written and the gate is run on the owner's development machine (rustup, rustc 1.98.1 on
|
|
2026-09-17), then deployed to straylight. `rust-version` is 1.95 because that is the rustc in
|
|
straylight's nixpkgs, where M7 will build the deployed binaries. `cargo-deny` 0.20.2 is installed
|
|
on the development machine with `cargo install --locked cargo-deny`. A Nix package and dev shell
|
|
are left to M7.
|
|
- `make gate`, offline: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D
|
|
warnings`; `cargo test --workspace`; `cargo deny check bans licenses sources`; and three scripts:
|
|
no source file over 500 lines, no role crate depends on another role crate, every dependency in
|
|
any `Cargo.toml` is named in `docs/dependencies.md`.
|
|
- `make audit` runs `cargo deny check advisories`. It fetches the advisory database, so it is not
|
|
part of the offline gate and is listed in `docs/egress.md` as development-time egress.
|
|
- `make verify-device` is created empty in M1 and filled from M2.
|
|
|
|
Dependencies verified on 2026-09-17 (crates.io and docs.rs):
|
|
|
|
| Crate | Version | Used for | Notes |
|
|
|---|---|---|---|
|
|
| `serde` (derive) | 1.0.229 | All `proto` types | MIT OR Apache-2.0 |
|
|
| `serde_json` | 1.0.151 | Frames, logs | MIT OR Apache-2.0 |
|
|
| `humantime` | 2.4.0 | RFC 3339 timestamps | No runtime dependencies. `format_rfc3339_millis(SystemTime) -> Rfc3339Timestamp`, `parse_rfc3339(&str) -> Result<SystemTime, Error>` |
|
|
| `toml` | 1.1.6 | Grant files; a dev-dependency of `proto` in M1 | MIT OR Apache-2.0 |
|
|
|
|
`trybuild` was considered for the compile-fail test and rejected: it brings a large
|
|
dev-dependency tree and its expected-output files differ between compiler versions. A
|
|
`compile_fail` doctest needs no dependency.
|
|
|
|
## 9. How the implementing model gets the work
|
|
|
|
Implementation is done by Laguna S 2.1. The model is served by straylight; OpenCode runs on the
|
|
owner's development machine in `~/src/boxmaker`, where the code is written and the gate is run.
|
|
Builds are deployed to straylight afterwards.
|
|
|
|
1. `AGENTS.md` holds the standing rules and stays under about 1,500 tokens, because OpenCode loads
|
|
it in every session.
|
|
2. Each task is one file, `docs/plans/M<n>/NN-name.md`, self-contained and under about 3,000
|
|
tokens: goal, exact paths, given signatures, verified crate versions and API snippets, the
|
|
tests to write first, commands with expected output, a "done when" list and a "stop and report
|
|
if" list. It quotes the part of the brief it needs.
|
|
3. One task is one fresh OpenCode session and one commit, on a branch named for the milestone. The
|
|
owner starts each session with: "Read `docs/plans/M1/NN-name.md` and do exactly that task."
|
|
4. Fixture files that define the wire format are written by the design model and committed with
|
|
the plan. For the security-critical parts of M3 the tests are too.
|
|
5. Laguna keeps `docs/implementer-log.md`: one row per task with the date, how many gate runs it
|
|
took, whether the first run passed, any deviation from the task, and anything it stopped on.
|
|
6. Review is once per milestone, by the design model. This is an experiment for M1: after the M1
|
|
review the owner decides whether to keep it or go back to reviewing each task. The review
|
|
checks the branch against the plan and runs the gate on the development machine. Findings go
|
|
back as follow-up tasks for Laguna. Trivial or blocking findings may be fixed directly, and the
|
|
log says so.
|
|
7. Because nobody checks the work between tasks in M1, each task's "done when" list ends with
|
|
`make gate` passing, and a task that cannot reach that state stops and reports instead of
|
|
moving on.
|
|
|
|
## 10. Not covered here
|
|
|
|
Channel and approval message bodies (M2 to M4). Grant constraint matching, the tool container
|
|
contract and image pinning (M3). The fake `llama-server` for offline tests, the hand-written HTTP
|
|
client, and who writes the compaction summary (M2). The repository licence (open decision).
|