Close the remaining M3a spec review findings
brokerd internals: the ledger lock over the audit writer and session state, the pending table's take-it-and-answer rule, sticky audit_unavailable after a failed write, pure policy functions with an Ask type that only redecide turns into a Decision, how a waiting thread detects a lost connection without peek, fixed RunError text. Approvals: bxctl chat fetches the block from brokerd by id, shows the parsed arguments with invisible and bidi characters escaped, asks for the approval id instead of y, and escapes model text. A tool_denied event carries the reason to the owner. Also: BrokerPort timeouts, tests for runbook pointers and concurrency, threat-model notes, P14 for broker/sessions in the brief's State list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ Newest first. A decision that changes `docs/design.md` lands in the same commit
|
|||||||
|
|
||||||
| Date | Decision | Reason |
|
| Date | Decision | Reason |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-09-18 | Spec review of M3a, approvals. The approval block in `bxctl chat` is fetched from `brokerd` by id, never taken from `loopd`'s event. It shows the parsed arguments serialised again, with control, invisible and direction-changing characters as `\uXXXX`. The owner types the approval id, not `y`. `bxctl chat` escapes model text the same way. A `tool_denied` turn event carries the reason to the owner. | A compromised `loopd` must not choose what the owner approves; `/etc` and `/etc` must look alike; U+202E can make a path read as another; a line already waiting in stdin must never approve; `chat` wrote model text raw, so a conceal sequence could hide the block. |
|
||||||
|
| 2026-09-18 | Spec review of M3a, `brokerd` internals. One lock (the ledger) covers the audit writer and the session state files; the pending table has its own, and whoever removes an entry answers it. After any failed audit write, `brokerd` denies with `audit_unavailable` until restarted. `policy` is pure, and `decide` never returns a `Decision` for an `ask` grant: only `redecide` turns an `Ask` into one. A `RunError`'s text is fixed text, never tool output, and a failure does not raise taint. `BrokerPort` waits `[broker] timeout_ms` for a first frame, and until `expires` plus that after a pending one. | Without the lock two results could lower a session's taint. A partial line followed by more records is a broken chain. `loopd` logs failures as `public`, so tool output in one would be unlabelled. |
|
||||||
| 2026-09-18 | Spec review of M3a, policy. A `deny` grant must have `max_taint = "secret"` or the grant set is invalid. A result's `result_class` and `untrusted` are combined over every matching grant (highest class; untrusted if any says so), and within a mode the grant with the longest matched path wins before the id tie-break. A grant path of `/` is invalid. | A `deny` grant with a lower `max_taint` stopped denying once the session read a secret. With overlapping `auto` grants the id tie-break chose the label, so a secret could come back `private`. M3b mounts grant paths at the same path, where `/` would replace the tool image. |
|
| 2026-09-18 | Spec review of M3a, policy. A `deny` grant must have `max_taint = "secret"` or the grant set is invalid. A result's `result_class` and `untrusted` are combined over every matching grant (highest class; untrusted if any says so), and within a mode the grant with the longest matched path wins before the id tie-break. A grant path of `/` is invalid. | A `deny` grant with a lower `max_taint` stopped denying once the session read a secret. With overlapping `auto` grants the id tie-break chose the label, so a secret could come back `private`. M3b mounts grant paths at the same path, where `/` would replace the tool image. |
|
||||||
| 2026-09-18 | Spec review of M3a, audit. A line followed by a matching `Recovery` is recovered whether or not it parses; an unparseable last line is recovered like a torn one. The writer never moves to an earlier day's file, and `Recovery` and `AcceptedBreak` go in the latest file. `--accept-break` verifies the whole log; the verifier checks an `AcceptedBreak`'s `file`, `line`, `last_good`, `prev` and `seq`, and the `seq` is counted from lines, not read from them. The `Approval` record carries the re-decision's grant and session state. The report lists allowed calls with no `Result`. | As first written, a record cut exactly before its newline made `brokerd` break its own chain; so did a clock stepped back over midnight; a break in an older file could never be accepted; and a damaged line could choose the next `seq`. |
|
| 2026-09-18 | Spec review of M3a, audit. A line followed by a matching `Recovery` is recovered whether or not it parses; an unparseable last line is recovered like a torn one. The writer never moves to an earlier day's file, and `Recovery` and `AcceptedBreak` go in the latest file. `--accept-break` verifies the whole log; the verifier checks an `AcceptedBreak`'s `file`, `line`, `last_good`, `prev` and `seq`, and the `seq` is counted from lines, not read from them. The `Approval` record carries the re-decision's grant and session state. The report lists allowed calls with no `Result`. | As first written, a record cut exactly before its newline made `brokerd` break its own chain; so did a clock stepped back over midnight; a break in an older file could never be accepted; and a damaged line could choose the next `seq`. |
|
||||||
| 2026-09-18 | M3 is split. M3a is the decision path: grants and matching, session taint, the hash-chained audit log, approvals through `bxctl`, the `broker.sock` and `admin.sock` protocol, and `loopd`'s `BrokerPort`; tools do not run (the runner is a trait with a refusing implementation). M3b is the Podman runner, the four tools and the egress proxy. Review happens after each. | The security logic is reviewed before real tools run on it, as with M2a and M2b. Spec `docs/specs/2026-09-18-m3a-decision-path.md`. |
|
| 2026-09-18 | M3 is split. M3a is the decision path: grants and matching, session taint, the hash-chained audit log, approvals through `bxctl`, the `broker.sock` and `admin.sock` protocol, and `loopd`'s `BrokerPort`; tools do not run (the runner is a trait with a refusing implementation). M3b is the Podman runner, the four tools and the egress proxy. Review happens after each. | The security logic is reviewed before real tools run on it, as with M2a and M2b. Spec `docs/specs/2026-09-18-m3a-decision-path.md`. |
|
||||||
@@ -76,3 +78,4 @@ Later changes to the brief:
|
|||||||
| # | Change | Reason |
|
| # | Change | Reason |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| P13 | Authority contract 4: add "Tool containers run from one image built from source by Nix and named by digest; nothing is pulled at call time." | The brief does not say where tool images come from; a pull at call time would be unlisted egress. Decided 2026-09-18 above. |
|
| P13 | Authority contract 4: add "Tool containers run from one image built from source by Nix and named by digest; nothing is pulled at call time." | The brief does not say where tool images come from; a pull at call time would be unlisted egress. Decided 2026-09-18 above. |
|
||||||
|
| P14 | State: add "`broker/sessions/<id>.json` — each session's taint and untrusted flag, written only by `brokerd`". | The M3a spec keeps this state in files the brief's list does not name. It can be rebuilt from the audit log's `result` records, so the audit log stays the record of truth. |
|
||||||
|
|||||||
+13
-8
@@ -69,9 +69,12 @@ file system.
|
|||||||
|
|
||||||
**Fix.** Free space or correct ownership (`chown` to the `brokerd` user; files are mode 0600, the
|
**Fix.** Free space or correct ownership (`chown` to the `brokerd` user; files are mode 0600, the
|
||||||
directory 0700). Do not edit, move or delete audit files to make space: that breaks the chain.
|
directory 0700). Do not edit, move or delete audit files to make space: that breaks the chain.
|
||||||
|
Then stop and start `brokerd`. It does not try again by itself: a failed write may have left part
|
||||||
|
of a line, and only the startup check deals with that. Expect
|
||||||
|
[audit-recovered](#audit-recovered) at that start.
|
||||||
|
|
||||||
**Check.** Ask for any tool call. It is decided normally, and `bxctl audit verify` prints
|
**Check.** Ask for any tool call. It is decided normally, and `bxctl audit verify` prints
|
||||||
`audit: ok`. If `brokerd` keeps failing after the cause is gone, stop and start it.
|
`audit: ok`.
|
||||||
|
|
||||||
## audit-chain-broken
|
## audit-chain-broken
|
||||||
|
|
||||||
@@ -205,12 +208,14 @@ again.
|
|||||||
|
|
||||||
## broker-unavailable
|
## broker-unavailable
|
||||||
|
|
||||||
**What you see.** `loopd` prints that it cannot reach `broker.sock`, then this entry. Tool calls
|
**What you see.** `loopd` prints that it cannot reach `broker.sock`, or that `brokerd` did not
|
||||||
fail with "the tool broker is unavailable", the model says so, and the conversation goes on.
|
answer in time, then this entry. Tool calls fail with "the tool broker is unavailable", the model
|
||||||
|
says so, and the conversation goes on. Or, once at startup, `loopd` prints "no tool broker is
|
||||||
|
configured" and this entry, and tool calls fail with those words.
|
||||||
|
|
||||||
**Why.** `brokerd` is not running, was restarted during a call, or `loopd`'s `[broker] socket`
|
**Why.** `brokerd` is not running, was restarted during a call, did not answer within `[broker]
|
||||||
points somewhere else. Pending approvals are lost when `brokerd` restarts. `bxctl audit verify`
|
timeout_ms`, or `loopd`'s `[broker] socket` points somewhere else or is not set. Pending approvals
|
||||||
lists them as abandoned.
|
are lost when `brokerd` restarts. `bxctl audit verify` lists them as pending or abandoned.
|
||||||
|
|
||||||
**Confirm.**
|
**Confirm.**
|
||||||
|
|
||||||
@@ -222,8 +227,8 @@ ls -l "$BOXMAKER_HOME/run/loop-broker/"
|
|||||||
Compare the socket path with `[broker] socket` in `loopd`'s config and `[sockets] broker` in
|
Compare the socket path with `[broker] socket` in `loopd`'s config and `[sockets] broker` in
|
||||||
`brokerd.toml`.
|
`brokerd.toml`.
|
||||||
|
|
||||||
**Fix.** Start `brokerd`, or make the two paths agree. `loopd` needs no restart: it connects per
|
**Fix.** Start `brokerd`, or make the two paths agree. `loopd` needs no restart for that: it
|
||||||
call.
|
connects per call. A changed or newly set `[broker] socket` does need a `loopd` restart.
|
||||||
|
|
||||||
**Check.** Ask the model to use a tool. The call is decided (allowed, asked or denied) instead of
|
**Check.** Ask the model to use a tool. The call is decided (allowed, asked or denied) instead of
|
||||||
failing.
|
failing.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# M3a design: the decision path
|
# M3a design: the decision path
|
||||||
|
|
||||||
Status: draft for owner review, 2026-09-18. M3 is split in two (`docs/decisions.md`). M3a is
|
Status: draft for owner review, 2026-09-18; revised the same day after a design review (the
|
||||||
|
"Spec review of M3a" rows in `docs/decisions.md`). M3 is split in two (`docs/decisions.md`). M3a is
|
||||||
everything that decides whether a tool call may run and records it: grant files and matching,
|
everything that decides whether a tool call may run and records it: grant files and matching,
|
||||||
session taint, the audit log, approvals through `bxctl`, the two `brokerd` sockets, and `loopd`'s
|
session taint, the audit log, approvals through `bxctl`, the two `brokerd` sockets, and `loopd`'s
|
||||||
real tool port. Tools themselves do not run in M3a: the runner is a trait whose only
|
real tool port. Tools themselves do not run in M3a: the runner is a trait whose only
|
||||||
@@ -19,7 +20,7 @@ in `docs/specs/2026-09-17-pre-m1-design.md`; section 6 of this document replaces
|
|||||||
| `loopd` cannot approve: the tool socket refuses every admin message | Socket tests |
|
| `loopd` cannot approve: the tool socket refuses every admin message | Socket tests |
|
||||||
| An approval only lets through a call that is still `ask` when approved | Approval tests |
|
| An approval only lets through a call that is still `ask` when approved | Approval tests |
|
||||||
| A denial reaches the model as a plain tool result and the turn goes on | End-to-end test in one process; on straylight with Ornith |
|
| A denial reaches the model as a plain tool result and the turn goes on | End-to-end test in one process; on straylight with Ornith |
|
||||||
| Every fail-closed state names a runbook entry that exists | Gate script |
|
| Every fail-closed state's message names a runbook entry, and every entry named exists | A test per state for the pointer; the gate script for the entry |
|
||||||
|
|
||||||
Out of scope: running tools, containers, the egress proxy, tool timeouts (all M3b); secrets
|
Out of scope: running tools, containers, the egress proxy, tool timeouts (all M3b); secrets
|
||||||
(moved to M4); approvals over Mattermost (M4); job-scoped grants (M5).
|
(moved to M4); approvals over Mattermost (M4); job-scoped grants (M5).
|
||||||
@@ -56,6 +57,30 @@ Each is one file under 500 lines with one purpose. Only `serve` starts threads.
|
|||||||
`brokerd serve --config <path> [--accept-break]`. `brokerd` gains `serde`, `serde_json` and
|
`brokerd serve --config <path> [--accept-break]`. `brokerd` gains `serde`, `serde_json` and
|
||||||
`toml`, all already vetted.
|
`toml`, all already vetted.
|
||||||
|
|
||||||
|
### Threads and locks
|
||||||
|
|
||||||
|
`serve` starts one thread per connection and one expiry thread. They share two things, each behind
|
||||||
|
its own `Mutex`, and no thread ever holds both at once:
|
||||||
|
|
||||||
|
- **The ledger**: the audit writer and every session state file. A thread holds it for each of
|
||||||
|
these steps as a whole, and never while a tool runs or an approval is awaited:
|
||||||
|
- read the session's state, decide, write the `Decision` record;
|
||||||
|
- read the state, re-decide, write the `Approval` record;
|
||||||
|
- read the state, raise it, write the state file, write the `Result` record.
|
||||||
|
|
||||||
|
Without it two results for one session could each read `private`, and the second write would
|
||||||
|
put the taint back down; and two records could take the same `seq`.
|
||||||
|
- **The pending table** (section 6). An entry is answered by whoever removes it from the table
|
||||||
|
while holding the table's lock: `approve`, `refuse`, the expiry thread, or the waiting thread
|
||||||
|
when it finds its connection closed. Everyone else finds the entry gone. This one rule settles
|
||||||
|
every race between them.
|
||||||
|
|
||||||
|
A poisoned table lock is recovered with `into_inner`, as in `loopd`. A poisoned ledger lock is
|
||||||
|
not: a thread panicked part-way through a write, so the writer's idea of the chain's head may be
|
||||||
|
wrong. Every call is then denied with `audit_unavailable` until `brokerd` is restarted, and the
|
||||||
|
startup check puts the tail right. The same holds after any failed audit write: the writer does
|
||||||
|
not write again in this process.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -222,6 +247,39 @@ IP literals, no uppercase, no `[`); no userinfo (`@` before the path); no port,
|
|||||||
then optionally `/` and a path, query or fragment of printable ASCII other than space. Anything
|
then optionally `/` and a path, query or fragment of printable ASCII other than space. Anything
|
||||||
else is `invalid_arguments`.
|
else is `invalid_arguments`.
|
||||||
|
|
||||||
|
### The policy functions
|
||||||
|
|
||||||
|
`policy` does no I/O and reads no clock: everything it needs is an argument, which is what lets
|
||||||
|
the property test drive it.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SessionState { pub taint: DataClass, pub untrusted: bool }
|
||||||
|
|
||||||
|
pub enum Outcome {
|
||||||
|
Allowed(Decision),
|
||||||
|
Ask(Ask),
|
||||||
|
Denied { reason: DenyReason, grant: Option<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decide(request: ToolRequest, grants: &GrantSet, state: SessionState, now: Timestamp)
|
||||||
|
-> Outcome;
|
||||||
|
pub fn redecide(ask: Ask, grants: &GrantSet, state: SessionState, now: Timestamp)
|
||||||
|
-> Result<Decision, DenyReason>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Ask` is built like `Decision`: private fields, no `Clone`, no `Deserialize`, a `compile_fail`
|
||||||
|
doctest. It holds the request, the parsed arguments and the grant that asked. **`decide` never
|
||||||
|
returns a `Decision` for an `ask` grant**, and `redecide` is the only thing that turns an `Ask`
|
||||||
|
into a `Decision`, so "this call was approved" is a fact about the types, not about the order
|
||||||
|
of statements in `broker`.
|
||||||
|
- `redecide` runs the same matching again. `ask` and `auto` both give a `Decision`; anything else
|
||||||
|
gives the reason.
|
||||||
|
- `broker` checks in this order, and the first that applies is the answer: the grant set is
|
||||||
|
invalid (`grants_invalid`); the session's state cannot be read (`state_unreadable`); then
|
||||||
|
`decide`, inside which: the tool is not one of the four (`no_grant`, arguments not parsed); the
|
||||||
|
arguments are not valid (`invalid_arguments`); matching. The first two need no `Decision`-like
|
||||||
|
guard: anyone may deny.
|
||||||
|
|
||||||
## 4. Session state
|
## 4. Session state
|
||||||
|
|
||||||
`<home>/broker/sessions/<id>.json`, written only by `brokerd`:
|
`<home>/broker/sessions/<id>.json`, written only by `brokerd`:
|
||||||
@@ -231,11 +289,14 @@ else is `invalid_arguments`.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- A session with no file is at `private`, trusted. The file is created at its first result.
|
- A session with no file is at `private`, trusted. The file is created at its first result.
|
||||||
- After a tool result, taint becomes the higher of its current value and the grant's
|
- After a tool result, taint becomes the higher of its current value and the result's
|
||||||
`result_class`; a grant with `untrusted = true` sets the flag. Neither ever goes down.
|
`result_class`, and an `untrusted` result sets the flag (both as combined in section 3, step 4).
|
||||||
|
Neither ever goes down. The read, the comparison and the write happen under the ledger lock
|
||||||
|
(section 2). A `failed` call changes nothing: its message is `brokerd`'s own text (section 7).
|
||||||
- Writes are atomic: write `<id>.json.tmp`, `fsync`, rename over `<id>.json`, `fsync` the directory.
|
- Writes are atomic: write `<id>.json.tmp`, `fsync`, rename over `<id>.json`, `fsync` the directory.
|
||||||
- A file that exists but cannot be read or parsed is an error: every call for that session is
|
- A file that exists but cannot be read or parsed is an error: every call for that session is
|
||||||
denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`.
|
denied with `state_unreadable` and `brokerd` prints `see docs/runbook.md#broker-state-damaged`.
|
||||||
|
- The brief's State list does not name this directory yet; P14 in `docs/decisions.md` proposes it.
|
||||||
- `loopd` has no access to `<home>/broker/`. The `class` and `untrusted` values it logs are a copy
|
- `loopd` has no access to `<home>/broker/`. The `class` and `untrusted` values it logs are a copy
|
||||||
for its own use and never an input to policy.
|
for its own use and never an input to policy.
|
||||||
|
|
||||||
@@ -286,8 +347,16 @@ pub enum DecisionRecord { Allowed, Ask, Denied { reason: DenyReason } }
|
|||||||
`DecisionRecord` keeps the name the brief uses for the plain record of a decision; its M1 shape
|
`DecisionRecord` keeps the name the brief uses for the plain record of a decision; its M1 shape
|
||||||
(with `grant` inside and an `Approved` variant) is replaced, since approval is now its own event.
|
(with `grant` inside and an `Approved` variant) is replaced, since approval is now its own event.
|
||||||
|
|
||||||
Results are recorded by hash and size, never content: a `read_file` of a secret must not copy the
|
`event` is a nested object: `{"seq":…,"time":…,"prev":…,"event":{"type":"decision",…}}`. It is not
|
||||||
secret into the audit log. Arguments are recorded in full; they are at most one frame (1 MiB).
|
flattened: `#[serde(flatten)]` does not work with `deny_unknown_fields`.
|
||||||
|
|
||||||
|
Results are recorded by hash and size, never content, so reading a secret does not by itself copy
|
||||||
|
it into the audit log. Arguments are recorded in full, exactly as received; they are at most one
|
||||||
|
frame (1 MiB). A session that has read a secret can put it in a later call's arguments (a
|
||||||
|
`write_file` content, a `shell` command), so the log is not free of secrets: a record is as
|
||||||
|
sensitive as the `taint` it carries, and the files are mode 0600 for that reason. For a `failed`
|
||||||
|
result, `class` and `untrusted` are the label the result would have had, and `taint_after` equals
|
||||||
|
the taint before.
|
||||||
|
|
||||||
### The chain
|
### The chain
|
||||||
|
|
||||||
@@ -307,8 +376,10 @@ secret into the audit log. Arguments are recorded in full; they are at most one
|
|||||||
backwards time as a clock warning.
|
backwards time as a clock warning.
|
||||||
- `Recovery` and `AcceptedBreak` records always go in the latest file, whatever their date: they
|
- `Recovery` and `AcceptedBreak` records always go in the latest file, whatever their date: they
|
||||||
belong next to the lines they describe.
|
belong next to the lines they describe.
|
||||||
- `brokerd` is the only writer. It holds an exclusive `flock` on `audit/.lock` for its whole life
|
- `brokerd` is the only writer. It holds an exclusive lock on `audit/.lock` for its whole life
|
||||||
and `fsync`s after every record.
|
and `fsync`s after every record. The lock is `std::fs::File::try_lock`, stable since Rust 1.89
|
||||||
|
(checked in the std docs of 1.98.1; the workspace minimum is 1.95), so it needs neither `libc`
|
||||||
|
nor `unsafe`. The task must check its error type on docs.rs before use.
|
||||||
|
|
||||||
### Write order
|
### Write order
|
||||||
|
|
||||||
@@ -316,7 +387,9 @@ For every tool request:
|
|||||||
|
|
||||||
1. Decide (section 3). Write the `Decision` record and sync it. **If this write fails, nothing
|
1. Decide (section 3). Write the `Decision` record and sync it. **If this write fails, nothing
|
||||||
runs**: the answer is `denied` with `audit_unavailable`, and `brokerd` prints
|
runs**: the answer is `denied` with `audit_unavailable`, and `brokerd` prints
|
||||||
`see docs/runbook.md#audit-unavailable`.
|
`see docs/runbook.md#audit-unavailable`. A failed write may have left part of a line, so the
|
||||||
|
writer does not write again: every later call gets the same denial until `brokerd` is
|
||||||
|
restarted, and the startup check recovers the tail.
|
||||||
2. If `ask`: wait (section 6). Write the `Approval` record. If it fails, the call does not run and
|
2. If `ask`: wait (section 6). Write the `Approval` record. If it fails, the call does not run and
|
||||||
the answer is `denied` with `audit_unavailable`.
|
the answer is `denied` with `audit_unavailable`.
|
||||||
3. If allowed: run. Update the session state file. Write the `Result` record. Then answer `loopd`.
|
3. If allowed: run. Update the session state file. Write the `Result` record. Then answer `loopd`.
|
||||||
@@ -336,7 +409,7 @@ impl ChainVerifier {
|
|||||||
pub fn new() -> Self; // expects seq 0, prev zero
|
pub fn new() -> Self; // expects seq 0, prev zero
|
||||||
pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue from a known point
|
pub fn resume(next_seq: u64, prev: Hash32) -> Self; // continue from a known point
|
||||||
pub fn file(&mut self, name: &str); // starts the next file
|
pub fn file(&mut self, name: &str); // starts the next file
|
||||||
pub fn line(&mut self, bytes: &[u8], last_in_file: bool, has_newline: bool);
|
pub fn line(&mut self, bytes: &[u8], has_newline: bool); // false only for a file's last line
|
||||||
pub fn finish(self) -> ChainReport;
|
pub fn finish(self) -> ChainReport;
|
||||||
}
|
}
|
||||||
pub struct ChainReport {
|
pub struct ChainReport {
|
||||||
@@ -427,9 +500,13 @@ Nothing is truncated, rewritten or deleted, ever.
|
|||||||
| `broker.sock` | `run/loop-broker/` | `loopd` | `tool_request` |
|
| `broker.sock` | `run/loop-broker/` | `loopd` | `tool_request` |
|
||||||
| `admin.sock` | `run/owner-broker/` | `bxctl` | `approvals`, `approve`, `refuse`, `check_grants` |
|
| `admin.sock` | `run/owner-broker/` | `bxctl` | `approvals`, `approve`, `refuse`, `check_grants` |
|
||||||
|
|
||||||
`brokerd` creates each directory with mode 0700 if it is missing, removes a stale socket file,
|
`brokerd` creates each directory if it is missing and sets its mode to 0700 whether it made it or
|
||||||
binds, and sets the socket to 0600. Any other message kind on a socket is answered with `error`
|
found it (a failure to do so is a startup error), removes a stale socket file, binds, and sets the
|
||||||
`forbidden`, and the connection is closed. `brokerd` prints the kind and the socket, with
|
socket to 0600. All of this comes after the audit lock is taken (section 5, "Startup"): the lock
|
||||||
|
is what proves the socket file is stale and not another `brokerd`'s.
|
||||||
|
|
||||||
|
Any other message kind on a socket is answered with `error` `forbidden`, and the connection is
|
||||||
|
closed. `brokerd` prints the kind and the socket, with
|
||||||
`see docs/runbook.md#socket-forbidden`: nothing in the harness sends a wrong kind, so it means a
|
`see docs/runbook.md#socket-forbidden`: nothing in the harness sends a wrong kind, so it means a
|
||||||
bug or a component doing what it should not.
|
bug or a component doing what it should not.
|
||||||
|
|
||||||
@@ -452,17 +529,35 @@ One connection per request, as on `loop.sock`:
|
|||||||
### Approvals
|
### Approvals
|
||||||
|
|
||||||
- An `ask` call is added to the pending table with `expires` = now + `ttl_ms`, or the grant's
|
- An `ask` call is added to the pending table with `expires` = now + `ttl_ms`, or the grant's
|
||||||
`expires` if that is earlier. `brokerd` sends the pending frame and waits.
|
`expires` if that is earlier. The entry holds the `Ask`, what `approvals` lists, and the sending
|
||||||
- **Approve.** `brokerd` decides again (`redecide`) with the grants and session state as they are
|
half of a `std::sync::mpsc` channel. The connection's thread sends the pending frame and waits
|
||||||
now. An `ask` outcome lets the call run, and so does `auto` (the owner has since allowed it
|
on the receiving half for a verdict: run this `Decision`, or deny with this reason.
|
||||||
outright); the grant matched now is the one used and recorded. Any denial, from a `deny` grant
|
- **Whoever takes the entry out of the table answers it** (section 2), and does all of the
|
||||||
or from no grant still matching, denies the call with that reason. The `Approval` record carries
|
answering: the admin thread for `approve` and `refuse`, the expiry thread for expiry. That
|
||||||
`approved` and the re-decision.
|
thread takes the ledger lock, writes the `Approval` record, sends the verdict, and only then
|
||||||
|
answers `bxctl`. The waiting thread writes nothing for an approval; it receives the verdict and
|
||||||
|
either runs the call or sends the denial.
|
||||||
|
- **Approve.** The admin thread decides again (`redecide`) with the grants and session state as
|
||||||
|
they are now. An `ask` outcome lets the call run, and so does `auto` (the owner has since
|
||||||
|
allowed it outright); the grant matched now is the one used and recorded. Any denial, from a
|
||||||
|
`deny` grant or from no grant still matching, denies the call with that reason. The `Approval`
|
||||||
|
record carries `approved` and the re-decision. If that record cannot be written, the verdict and
|
||||||
|
the `approve_result` are both `denied` with `audit_unavailable`.
|
||||||
- **Refuse.** Denied with `approval_refused`.
|
- **Refuse.** Denied with `approval_refused`.
|
||||||
- **Expiry.** A thread checks the table every second. An expired approval is denied with
|
- **Expiry.** A thread checks the table every second. An expired approval is denied with
|
||||||
`approval_expired`.
|
`approval_expired`.
|
||||||
- **Lost connection.** If `loopd`'s connection closes while pending, the entry is removed and
|
- **Lost connection.** While it waits, the connection's thread wakes every second
|
||||||
nothing is written. `bxctl audit verify` reports the decision as abandoned.
|
(`recv_timeout`) and checks its socket: with a read timeout of 10 ms set, a `read` that returns
|
||||||
|
`Ok(0)` means `loopd` has gone; a `WouldBlock` or `TimedOut` error means it is still there; any
|
||||||
|
bytes are a protocol error and count as gone. (`UnixStream::peek` would be the natural call, but
|
||||||
|
it is nightly-only; a zero read timeout is an error.) If `loopd` has gone, the thread tries to
|
||||||
|
take its own entry out of the table. If it gets it, nothing is written, and
|
||||||
|
`bxctl audit verify` reports the decision as abandoned. If the entry is already gone, someone
|
||||||
|
is answering it: the thread waits for the verdict.
|
||||||
|
- **One more check before running.** On a verdict to run, the thread checks its socket once more.
|
||||||
|
If `loopd` has gone, the call does not run, and a `Result` record with status `failed` (message
|
||||||
|
"the requester went away") closes the call in the log. `loopd` can still go away between this
|
||||||
|
check and the run, and the call then runs with nobody waiting for it; that is accepted.
|
||||||
- **Restart.** The table is in memory only. After a restart there are no pending approvals.
|
- **Restart.** The table is in memory only. After a restart there are no pending approvals.
|
||||||
|
|
||||||
### Admin messages, on `admin.sock`
|
### Admin messages, on `admin.sock`
|
||||||
@@ -485,6 +580,12 @@ pub struct GrantProblem { pub file: String, pub line: Option<u64>, pub problem:
|
|||||||
|
|
||||||
An unknown or already answered `approval` is `error` `no_such_approval`.
|
An unknown or already answered `approval` is `error` `no_such_approval`.
|
||||||
|
|
||||||
|
`PendingApproval.arguments` is not the string `loopd` sent. It is the parsed arguments serialised
|
||||||
|
again by `serde_json` from the typed value in `args`, fields in the order of section 3's table.
|
||||||
|
The owner approves what policy matched: in the raw string `"/etc"` and `"/etc"` look
|
||||||
|
different and mean the same, and the re-serialised form shows both as `/etc`. The audit log keeps
|
||||||
|
the raw string.
|
||||||
|
|
||||||
## 7. The runner seam
|
## 7. The runner seam
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -516,6 +617,13 @@ What `run` puts in the spec, from the decision:
|
|||||||
| `shell` | every path of the grant, writable | none |
|
| `shell` | every path of the grant, writable | none |
|
||||||
| `http_fetch` | none | the grant's `hosts` |
|
| `http_fetch` | none | the grant's `hosts` |
|
||||||
|
|
||||||
|
**A `RunError`'s text is never tool output.** `loopd` logs a `failed` result as `public` and
|
||||||
|
trusted, and a failure does not raise taint, so the message must be one of a fixed set of
|
||||||
|
sentences written in `brokerd` or `toolkit` ("the tool timed out", "the container could not
|
||||||
|
start"). Nothing a tool printed, read from a file or received from the network goes in it. In M3b
|
||||||
|
a command that exits non-zero is a `result`, labelled like any other, with its output and exit
|
||||||
|
status as the content.
|
||||||
|
|
||||||
M3a's production runtime is `Refusing`: every call is `RunError::Unavailable("the runner arrives
|
M3a's production runtime is `Refusing`: every call is `RunError::Unavailable("the runner arrives
|
||||||
in M3b")`, which becomes `failed` for `loopd`. Tests use a recording fake. Limits (timeout, memory,
|
in M3b")`, which becomes `failed` for `loopd`. Tests use a recording fake. Limits (timeout, memory,
|
||||||
processes, output size) are M3b's.
|
processes, output size) are M3b's.
|
||||||
@@ -524,9 +632,12 @@ processes, output size) are M3b's.
|
|||||||
|
|
||||||
- **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback:
|
- **`BrokerPort`** implements `ToolPort` over `broker.sock`. `ToolPort::call` gains a callback:
|
||||||
`fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`.
|
`fn call(&self, req: &ToolRequest, on_pending: &mut dyn FnMut(&Pending)) -> ToolResponse`.
|
||||||
The read timeout while waiting is the pending frame's `expires` plus 30 s. If the socket cannot
|
The read timeout is `[broker] timeout_ms` (default 120,000) until the first frame. After a
|
||||||
be reached or closes early, the answer is `Failed { "the tool broker is unavailable" }`, and
|
pending frame it runs until the frame's `expires` plus `timeout_ms`, which leaves a call
|
||||||
`loopd` prints `see docs/runbook.md#broker-unavailable`. The turn goes on.
|
approved at the last moment the same time to run as any other. M3b must keep its tool time
|
||||||
|
limit under `timeout_ms`. If the socket cannot be reached, closes early or times out, the answer
|
||||||
|
is `Failed { "the tool broker is unavailable" }`, and `loopd` prints
|
||||||
|
`see docs/runbook.md#broker-unavailable`. The turn goes on.
|
||||||
- **`clock` moves into `loopd`**, beside `find_tool` and `call_tool`: the time is not authority and
|
- **`clock` moves into `loopd`**, beside `find_tool` and `call_tool`: the time is not authority and
|
||||||
needs no broker. The core tools stay `clock`, `find_tool`, `call_tool`. The registry's
|
needs no broker. The core tools stay `clock`, `find_tool`, `call_tool`. The registry's
|
||||||
discoverable tools become `read_file`, `write_file`, `shell`, `http_fetch`, with the argument
|
discoverable tools become `read_file`, `write_file`, `shell`, `http_fetch`, with the argument
|
||||||
@@ -548,10 +659,14 @@ processes, output size) are M3b's.
|
|||||||
| `audit_unavailable` | Denied: the audit log cannot be written; the owner has been told. |
|
| `audit_unavailable` | Denied: the audit log cannot be written; the owner has been told. |
|
||||||
| `state_unreadable` | Denied: this session's broker state is damaged; the owner has been told. |
|
| `state_unreadable` | Denied: this session's broker state is damaged; the owner has been told. |
|
||||||
|
|
||||||
- **A new turn event**, `approval_pending { approval, tool, expires }`, sent when the pending frame
|
- **Two new turn events.** `approval_pending { approval, tool, expires }` is sent when the pending
|
||||||
arrives.
|
frame arrives. It is a notice that something is waiting, not a description of it: `loopd` does
|
||||||
- **Config**: `[broker] socket`. If absent, `loopd serve` uses no port and every tool call except
|
not know the grant or the taint, and what the owner is shown must not come from `loopd`
|
||||||
the core ones fails with "no tool broker is configured".
|
(section 9). `tool_denied { name, reason }` is sent before the `tool_result` event of a denied
|
||||||
|
call, so the owner sees the reason itself and not only the model's account of it.
|
||||||
|
- **Config**: `[broker] socket` and `[broker] timeout_ms`. If `socket` is absent, `loopd serve`
|
||||||
|
uses no port and every tool call except the core ones fails with "no tool broker is
|
||||||
|
configured"; `loopd` prints that once at startup with `see docs/runbook.md#broker-unavailable`.
|
||||||
- **Runbook pointers** on the existing fail-closed messages: the failed self-test
|
- **Runbook pointers** on the existing fail-closed messages: the failed self-test
|
||||||
(`loopd-selftest-failed`), a damaged session log (`session-log-damaged`), an unreadable
|
(`loopd-selftest-failed`), a damaged session log (`session-log-damaged`), an unreadable
|
||||||
`memory/core.md` (`core-memory-unreadable`).
|
`memory/core.md` (`core-memory-unreadable`).
|
||||||
@@ -565,8 +680,10 @@ processes, output size) are M3b's.
|
|||||||
shell {"command": "rm -rf /home/kyle/scratch/build", "cwd": "/home/kyle/scratch"}
|
shell {"command": "rm -rf /home/kyle/scratch/build", "cwd": "/home/kyle/scratch"}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `bxctl approve <id>` prints `approved 41: runs` or `approved 41: denied (<reason>)`.
|
- `bxctl approve <id>` prints `approved 41: runs` (exit status 0) or
|
||||||
`bxctl refuse <id> [--reason <text>]` prints `refused 41`.
|
`approved 41: denied (<reason>)` (exit status 1). `bxctl refuse <id> [--reason <text>]` prints
|
||||||
|
`refused 41`. For `no_such_approval` both print `41: no such approval (already answered or
|
||||||
|
expired)` and exit 1.
|
||||||
- `bxctl grants check` prints each problem as `<file>:<line>: <problem>`, or `grants: ok`; exit
|
- `bxctl grants check` prints each problem as `<file>:<line>: <problem>`, or `grants: ok`; exit
|
||||||
status 1 if there are problems.
|
status 1 if there are problems.
|
||||||
- `bxctl audit verify [--home <path>]` reads `<home>/audit/` itself (no daemon) and prints
|
- `bxctl audit verify [--home <path>]` reads `<home>/audit/` itself (no daemon) and prints
|
||||||
@@ -575,12 +692,30 @@ processes, output size) are M3b's.
|
|||||||
`<file>:<line>: <what>` and exit status 1. The two double names are because `bxctl` reads the
|
`<file>:<line>: <what>` and exit status 1. The two double names are because `bxctl` reads the
|
||||||
files without asking `brokerd`: an approval still waiting and a call still running look the same
|
files without asking `brokerd`: an approval still waiting and a call still running look the same
|
||||||
on disk as ones a crash cut off.
|
on disk as ones a crash cut off.
|
||||||
- `bxctl chat`: an `approval_pending` event prints the same two-line block as `approvals`. In the
|
- `bxctl chat`: on an `approval_pending` event it sends `approvals` on `admin.sock`, finds the
|
||||||
interactive mode it then asks `approve 41? [y/N] ` on stderr and reads one line from stdin; `y`
|
entry with that id, and prints the same two-line block as `bxctl approvals`. **Everything in the
|
||||||
sends `approve`, anything else sends `refuse`. `--say` and `--json` print the event only.
|
block comes from `brokerd`, nothing from the event**: a compromised `loopd` must not choose what
|
||||||
- **Arguments are printed as data.** Every character below U+0020, U+007F, and U+0080 to U+009F is
|
the owner sees. If the id is not in the list it prints `approval 41 is no longer pending` and
|
||||||
printed as `\u00XX`; so is U+001B wherever it appears. Nothing the model wrote can move the
|
does not ask. In the interactive mode it then asks `type 41 to approve, anything else refuses: `
|
||||||
cursor, change colours or hide text.
|
on stderr and reads one line from stdin; exactly the id sends `approve`, anything else sends
|
||||||
|
`refuse`. The id, not `y`, because lines typed while the turn ran are still waiting in stdin,
|
||||||
|
and `bxctl` cannot discard them without a terminal library: a stray line must never approve.
|
||||||
|
`--say` and `--json` print the event only. `bxctl chat` gains `--admin-socket`.
|
||||||
|
- A `tool_denied` event prints `[denied <name>: <reason>]`, and for `grants_invalid`,
|
||||||
|
`audit_unavailable` and `state_unreadable` the runbook pointer (`grants-invalid`,
|
||||||
|
`audit-unavailable`, `broker-state-damaged`) on the next line.
|
||||||
|
- **Arguments are printed as data.** Each of these characters is printed as `\uXXXX`, four
|
||||||
|
lowercase hex digits: U+0000 to U+001F, U+007F to U+009F, U+200B to U+200F, U+2028 to U+202E,
|
||||||
|
U+2060 to U+2069, and U+FEFF. The first two ranges are the control characters, so nothing the
|
||||||
|
model wrote can move the cursor or change colours. The rest are the invisible and
|
||||||
|
direction-changing characters: with U+202E in a path, the path displayed would differ from the
|
||||||
|
path that was matched. The arguments are JSON, and inside a JSON string `\uXXXX` is the standard
|
||||||
|
spelling of the same character, so the printed text is still exactly the arguments and cannot
|
||||||
|
be forged: a real backslash in a value is already `\\` there.
|
||||||
|
- **Model text is printed the same way** from M3a: `bxctl chat` applies the same escaping to
|
||||||
|
reasoning and content, except that newline and tab pass through, and writes `ESC[0m` before an
|
||||||
|
approval block. Until now it wrote model text to the terminal as it came, and a colour or
|
||||||
|
conceal sequence in it would have carried into the block that follows.
|
||||||
- The admin socket defaults to `<BOXMAKER_HOME>/run/owner-broker/admin.sock`.
|
- The admin socket defaults to `<BOXMAKER_HOME>/run/owner-broker/admin.sock`.
|
||||||
|
|
||||||
## 10. `proto` changes
|
## 10. `proto` changes
|
||||||
@@ -594,7 +729,7 @@ processes, output size) are M3b's.
|
|||||||
| `ErrorCode` | gains `forbidden`, `no_such_approval` |
|
| `ErrorCode` | gains `forbidden`, `no_such_approval` |
|
||||||
| `Message` | gains `approvals`, `approval_list`, `approve`, `approve_result`, `refuse`, `ok`, `check_grants`, `grants_report` |
|
| `Message` | gains `approvals`, `approval_list`, `approve`, `approve_result`, `refuse`, `ok`, `check_grants`, `grants_report` |
|
||||||
| `PendingApproval`, `GrantProblem` | Section 6 |
|
| `PendingApproval`, `GrantProblem` | Section 6 |
|
||||||
| `TurnEvent` | gains `approval_pending { approval, tool, expires }` |
|
| `TurnEvent` | gains `approval_pending { approval, tool, expires }` and `tool_denied { name, reason }` |
|
||||||
|
|
||||||
All of them reject unknown fields. Every new message kind gets a byte-exact fixture.
|
All of them reject unknown fields. Every new message kind gets a byte-exact fixture.
|
||||||
|
|
||||||
@@ -652,26 +787,49 @@ first, as in M2.
|
|||||||
- **Unfinished and abandoned.** An allowed `Decision` with no `Result` is listed in `unfinished`;
|
- **Unfinished and abandoned.** An allowed `Decision` with no `Result` is listed in `unfinished`;
|
||||||
an `Ask` with no `Approval` in `abandoned`; neither is a failure.
|
an `Ask` with no `Approval` in `abandoned`; neither is a failure.
|
||||||
- **Audit before action.** A writer that fails on demand: the answer is `audit_unavailable` and
|
- **Audit before action.** A writer that fails on demand: the answer is `audit_unavailable` and
|
||||||
the fake runtime's count is 0. A state writer that fails: `failed`, and the content is absent.
|
the fake runtime's count is 0; the next call is denied the same way though the writer would now
|
||||||
|
succeed. A state writer that fails: `failed`, and the content is absent.
|
||||||
|
- **Concurrency.** Eight threads, two sessions, fifty calls each through the `brokerd` library
|
||||||
|
with the recording runtime, half of the grants labelled `secret`: the log verifies, no `seq`
|
||||||
|
repeats, and each session's `taint_after` never goes down from one `Result` to the next.
|
||||||
- **Sockets.** Each admin kind on `broker.sock` and `tool_request` on `admin.sock` is `forbidden`.
|
- **Sockets.** Each admin kind on `broker.sock` and `tool_request` on `admin.sock` is `forbidden`.
|
||||||
Socket mode 0600, directory mode 0700.
|
Socket mode 0600; directory mode 0700, both when `brokerd` makes it and when it finds it at 0755.
|
||||||
- **Approvals.** Approve; refuse; expire with `ttl_ms` 100; approve after the grant file is
|
- **Approvals.** Approve; refuse; expire with `ttl_ms` 100; approve after the grant file is
|
||||||
removed (denied, `no_grant`); approve after the taint rose past `max_taint` (denied,
|
removed (denied, `no_grant`); approve after the taint rose past `max_taint` (denied,
|
||||||
`taint_too_high`); an unknown id; a dropped `loopd` connection while pending (removed, nothing
|
`taint_too_high`); an unknown id; a dropped `loopd` connection while pending (removed, nothing
|
||||||
written, reported as abandoned).
|
written, reported as abandoned); `approve` and `refuse` sent together from two threads, a
|
||||||
|
hundred times (exactly one `Approval` record each time, and the other caller gets
|
||||||
|
`no_such_approval`); an approval whose `Approval` record cannot be written (both sides get
|
||||||
|
`audit_unavailable`, runtime count 0); `loopd` gone at the check before running (a `failed`
|
||||||
|
`Result`, runtime count 0). The `Approval` record names the grant matched at the re-decision
|
||||||
|
when that differs from the first.
|
||||||
|
- **Pointers.** For each fail-closed state (`grants_invalid`, `audit_unavailable`,
|
||||||
|
`state_unreadable`, a broken chain, a recovered tail, a held lock, a forbidden message, the
|
||||||
|
broker unreachable, no broker configured), a test that its message ends with
|
||||||
|
`see docs/runbook.md#<anchor>`.
|
||||||
- **Runner seam.** The recording fake shows the mounts and egress of section 7 for each tool, and no
|
- **Runner seam.** The recording fake shows the mounts and egress of section 7 for each tool, and no
|
||||||
egress for anything but `http_fetch`. `compile_fail` for `RunSpec`.
|
egress for anything but `http_fetch`. `compile_fail` for `RunSpec`.
|
||||||
- **`loopd`.** `BrokerPort` against a fake `brokerd`: result, denial text, pending event, broker
|
- **`loopd`.** `BrokerPort` against a fake `brokerd`: result, denial text, pending event, broker
|
||||||
gone. The turn loop with `approval_pending`.
|
gone, a broker that accepts and never answers (times out after `timeout_ms`), a final frame that
|
||||||
- **`bxctl`.** `approvals` output with an argument full of escape sequences; `approve`, `refuse`,
|
arrives after `expires` but within `timeout_ms` of it (delivered). The turn loop with
|
||||||
`grants check` and `audit verify` against fakes and fixture directories.
|
`approval_pending` and `tool_denied`.
|
||||||
|
- **`bxctl`.** `approvals` output with an argument full of escape sequences and one with U+202E,
|
||||||
|
U+200B and U+2066; `/etc` in the request shown as `/etc`; `approve`, `refuse`,
|
||||||
|
`grants check` and `audit verify` against fakes and fixture directories. `chat` against a fake
|
||||||
|
`loopd` and a fake `brokerd`: when the event says `read_file` and `brokerd`'s entry says `shell`,
|
||||||
|
the block says `shell`;
|
||||||
|
`y` refuses and the id approves; a line already waiting in stdin refuses; an id missing from the
|
||||||
|
list prints "no longer pending" and asks nothing; model content holding `ESC[8m` comes out
|
||||||
|
escaped.
|
||||||
- **End to end, in one process.** The fake llama server, `loopd`'s turn loop, and the `brokerd`
|
- **End to end, in one process.** The fake llama server, `loopd`'s turn loop, and the `brokerd`
|
||||||
library with the recording runtime. The scripted model calls `call_tool` for `read_file` with no
|
library with the recording runtime. The scripted model calls `call_tool` for `read_file` with no
|
||||||
grant; its next request contains "Denied: no grant allows this call."; the audit log holds one
|
grant; its next request contains "Denied: no grant allows this call."; the audit log holds one
|
||||||
`Decision` record with `no_grant`.
|
`Decision` record with `no_grant`.
|
||||||
- **The runbook script**, with its self-test.
|
- **The runbook script**, with its self-test.
|
||||||
|
|
||||||
**On straylight** (a scripted check, not part of `verify-device`): `brokerd serve` and `loopd serve`
|
**On straylight** (a scripted check, not part of `verify-device`): the script first reads
|
||||||
|
`GET /slots?model=ornith-1.5-35b-a3b` and stops if slot 0 is busy, then uses slot 0 only, as
|
||||||
|
`verify-device` does. `brokerd serve` and `loopd serve`
|
||||||
with a test home holding one `ask` grant for `read_file`. A `bxctl chat` asks Ornith to read a file
|
with a test home holding one `ask` grant for `read_file`. A `bxctl chat` asks Ornith to read a file
|
||||||
in that directory; the approval block appears; approving it gives the M3a runner's failure, which
|
in that directory; the approval block appears; approving it gives the M3a runner's failure, which
|
||||||
the model reports. The audit log verifies, with `Decision`, `Approval` and `Result` records.
|
the model reports. The audit log verifies, with `Decision`, `Approval` and `Result` records.
|
||||||
@@ -699,6 +857,18 @@ accepts only allowlisted host names refuses other hosts and IP literals.
|
|||||||
fail and are reported as abandoned. Nothing runs without an approval record.
|
fail and are reported as abandoned. Nothing runs without an approval record.
|
||||||
- Results are audited by hash. Proving what a result was needs the content from elsewhere (the
|
- Results are audited by hash. Proving what a result was needs the content from elsewhere (the
|
||||||
session log holds it, capped).
|
session log holds it, capped).
|
||||||
|
- Taint is kept per session id, and `loopd` chooses the id. Injected text cannot change it, but a
|
||||||
|
compromised `loopd` can read a secret under one id and send it out under a fresh one, which
|
||||||
|
starts at `private`. Taint therefore contains the model, not a compromised `loopd`; what
|
||||||
|
contains that is the grants themselves (`ask` on anything that leaves the host). Accepted for
|
||||||
|
v0. A host-wide taint floor would close it and is not proposed here.
|
||||||
|
- Deleting `<home>/broker/sessions/<id>.json` puts a session back to `private` and nothing
|
||||||
|
notices; the audit log, by contrast, shows tampering. Only the owner's user can do it, and the
|
||||||
|
`Result` records still hold every `taint_after`. Accepted until M7 gives `brokerd` its own user.
|
||||||
|
- The deny reasons tell the model something about grants `loopd` cannot see: `grant_expired` and
|
||||||
|
`taint_too_high` reveal that a grant covers those arguments. Each probe is a call, so each is in
|
||||||
|
the audit log. Accepted: the reasons are worth more to the owner, through the model's
|
||||||
|
explanation, than their absence would cost an attacker.
|
||||||
|
|
||||||
## 15. How the work is handed over
|
## 15. How the work is handed over
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user