Files
boxmaker/docs/specs/2026-09-17-pre-m1-design.md
T
2026-09-17 01:09:08 -07:00

15 KiB

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.

{"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.

// 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.

// 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).