Fix seven M3a spec review findings in policy and the audit chain
Policy: a deny grant must apply at every taint; a result's label is combined over all matching grants and the longest matched path wins within a mode; a grant of / is invalid. Audit: a recovered line need not fail to parse; the writer never goes back to an earlier day's file; --accept-break verifies the whole log and the break record's fields are all checked, with a seq counted from lines; the Approval record carries the re-decision's grant and state; calls with no Result are reported. 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, 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 | 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`. |
|
||||||
| 2026-09-18 | Grant loading fails closed: one invalid grant file denies every call (`grants_invalid`) until it is fixed. M3 grants may not set `secret` or `patterns`; each tool has fixed rules for which constraints it takes. Among matching grants the most restrictive mode wins (deny, ask, auto). An approval re-decides against the current grants and taint, and runs only if the outcome is still ask or auto. | A skipped, mistyped `deny` grant would silently become an allow. Pattern-matching shell commands is a false comfort; the container is the boundary. |
|
| 2026-09-18 | Grant loading fails closed: one invalid grant file denies every call (`grants_invalid`) until it is fixed. M3 grants may not set `secret` or `patterns`; each tool has fixed rules for which constraints it takes. Among matching grants the most restrictive mode wins (deny, ask, auto). An approval re-decides against the current grants and taint, and runs only if the outcome is still ask or auto. | A skipped, mistyped `deny` grant would silently become an allow. Pattern-matching shell commands is a false comfort; the container is the boundary. |
|
||||||
| 2026-09-18 | Every fail-closed state has an entry in `docs/runbook.md` with what you see, why, how to confirm, how to fix and how to check; its message ends with `see docs/runbook.md#<entry>`, and a gate script checks that every referenced entry exists. | Owner's requirement: a refusing system must come with clear, actionable remediation. |
|
| 2026-09-18 | Every fail-closed state has an entry in `docs/runbook.md` with what you see, why, how to confirm, how to fix and how to check; its message ends with `see docs/runbook.md#<entry>`, and a gate script checks that every referenced entry exists. | Owner's requirement: a refusing system must come with clear, actionable remediation. |
|
||||||
|
|||||||
+21
-7
@@ -33,8 +33,10 @@ bxctl grants check
|
|||||||
|
|
||||||
It prints each problem as `<file>:<line>: <problem>`. Common ones: a misspelled field (unknown
|
It prints each problem as `<file>:<line>: <problem>`. Common ones: a misspelled field (unknown
|
||||||
fields are errors); `secret` set (not supported until M4); `patterns` set (not supported); `hosts`
|
fields are errors); `secret` set (not supported until M4); `patterns` set (not supported); `hosts`
|
||||||
on a file tool or `paths` on `http_fetch`; a relative path or one with `..`, `.` or `//`; a host
|
on a file tool or `paths` on `http_fetch`; a relative path or one with `..`, `.` or `//`; the path
|
||||||
with uppercase letters, a port, or an IP address; a file name with characters outside `[a-z0-9-]`.
|
`/` (grant the directories you mean); a `deny` grant whose `max_taint` is not `"secret"` (it would
|
||||||
|
stop denying once the session read a secret); a host with uppercase letters, a port, or an IP
|
||||||
|
address; a file name with characters outside `[a-z0-9-]`.
|
||||||
|
|
||||||
**Fix.** Edit the file, or move it out of `$GRANTS` while you work on it:
|
**Fix.** Edit the file, or move it out of `$GRANTS` while you work on it:
|
||||||
|
|
||||||
@@ -74,13 +76,19 @@ directory 0700). Do not edit, move or delete audit files to make space: that bre
|
|||||||
## audit-chain-broken
|
## audit-chain-broken
|
||||||
|
|
||||||
**What you see.** `brokerd` refuses to start and prints `<file>:<line>: <what>`, then this entry.
|
**What you see.** `brokerd` refuses to start and prints `<file>:<line>: <what>`, then this entry.
|
||||||
Or `bxctl audit verify` prints the same and exits 1.
|
Or `bxctl audit verify` prints the same and exits 1. If the break is in an older file, only
|
||||||
|
`bxctl audit verify` reports it: at an ordinary start `brokerd` checks the latest file alone.
|
||||||
|
|
||||||
**Why.** A record does not parse, its `seq` is not the next number, its `prev` is not the hash of
|
**Why.** A record does not parse, its `seq` is not the next number, its `prev` is not the hash of
|
||||||
the line before, or a file does not continue from the one before it. Something changed the log
|
the line before, or a file does not continue from the one before it. Something changed the log
|
||||||
after it was written: an edit, a deleted or reordered line, a file restored from a backup, a
|
after it was written: an edit, a deleted or reordered line, a file restored from a backup, a
|
||||||
partial copy. `brokerd` will not add records after a history it cannot vouch for.
|
partial copy. `brokerd` will not add records after a history it cannot vouch for.
|
||||||
|
|
||||||
|
There is one innocent cause. After a crash `brokerd` recovers a torn last line by itself
|
||||||
|
([audit-recovered](#audit-recovered)), but if it is killed a second time while writing that
|
||||||
|
recovery record, the log is left with a line that nothing describes. The failure is then within
|
||||||
|
the last three lines of the latest file, and the last line is cut short.
|
||||||
|
|
||||||
**Confirm.**
|
**Confirm.**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -98,11 +106,14 @@ cp -a "$BOXMAKER_HOME/audit" ~/audit-copy-$(date +%F)
|
|||||||
brokerd serve --config <path> --accept-break
|
brokerd serve --config <path> --accept-break
|
||||||
```
|
```
|
||||||
|
|
||||||
`brokerd` appends an `accepted_break` record naming the file and line, and continues from there.
|
With this flag `brokerd` checks the whole log, not only the latest file, and appends an
|
||||||
Nothing is repaired or deleted. The break stays visible in every later verification.
|
`accepted_break` record naming the file and line of the first failure. It continues from there.
|
||||||
|
Nothing is repaired or deleted. The break stays visible in every later verification. One accepted
|
||||||
|
break covers everything between the failure and the break record, including any further damage
|
||||||
|
there, so look at that whole stretch before accepting it.
|
||||||
|
|
||||||
**Check.** `bxctl audit verify` prints `audit: ok` and lists the accepted break with its file and
|
**Check.** `bxctl audit verify` prints `audit: ok` and lists the accepted break with its file and
|
||||||
line. A second failure later in the log needs its own accepted break.
|
line. Damage that happens after the break record needs its own accepted break.
|
||||||
|
|
||||||
## audit-recovered
|
## audit-recovered
|
||||||
|
|
||||||
@@ -112,7 +123,10 @@ It starts normally.
|
|||||||
**Why.** The last record was being written when `brokerd` or the machine stopped, so the file ended
|
**Why.** The last record was being written when `brokerd` or the machine stopped, so the file ended
|
||||||
without a newline. That is expected after a crash, not an error. `brokerd` kept the partial line,
|
without a newline. That is expected after a crash, not an error. `brokerd` kept the partial line,
|
||||||
ended it, and wrote a `recovery` record holding its length and hash, chained from the last complete
|
ended it, and wrote a `recovery` record holding its length and hash, chained from the last complete
|
||||||
record. The partial record was never acted on: records are written before the action they allow.
|
record. The partial line may even look like a whole record; it is not one, and the recovery record
|
||||||
|
takes its `seq`. It was never acted on: a record allows something only once it is on disk with its
|
||||||
|
newline. If the partial line was the `result` of a call, the call did run and its result was
|
||||||
|
never delivered; `bxctl audit verify` lists that call as unfinished.
|
||||||
|
|
||||||
**Confirm.** `bxctl audit verify` lists the recovery with its file and line.
|
**Confirm.** `bxctl audit verify` lists the recovery with its file and line.
|
||||||
|
|
||||||
|
|||||||
@@ -116,14 +116,23 @@ gives one, the line and field.
|
|||||||
| `shell` | optional | must be empty |
|
| `shell` | optional | must be empty |
|
||||||
| `http_fetch` | must be empty | required |
|
| `http_fetch` | must be empty | required |
|
||||||
|
|
||||||
7. A path in `paths` is not a valid absolute path (section 3, "Paths").
|
7. A path in `paths` is not a valid absolute path (section 3, "Paths"), or is `/`: "a grant of the
|
||||||
|
whole file system is not supported".
|
||||||
8. A host in `hosts` is not a valid host pattern (section 3, "Hosts").
|
8. A host in `hosts` is not a valid host pattern (section 3, "Hosts").
|
||||||
|
9. `mode` is `deny` and `max_taint` is not `secret`: "a deny grant must apply at every taint".
|
||||||
|
|
||||||
**If any file is invalid, the whole set is invalid**, and every call is denied with
|
**If any file is invalid, the whole set is invalid**, and every call is denied with
|
||||||
`grants_invalid` until it is fixed. `brokerd` prints each problem, once per distinct set of
|
`grants_invalid` until it is fixed. `brokerd` prints each problem, once per distinct set of
|
||||||
problems, followed by `see docs/runbook.md#grants-invalid`. The reason: a mistyped `deny` grant
|
problems, followed by `see docs/runbook.md#grants-invalid`. The reason: a mistyped `deny` grant
|
||||||
that was skipped would silently turn into an allow wherever another grant matches.
|
that was skipped would silently turn into an allow wherever another grant matches.
|
||||||
|
|
||||||
|
Rule 9 has the same reason. A grant stops applying when the session's taint passes its
|
||||||
|
`max_taint`, so a `deny` grant with `max_taint = "private"` would stop denying as soon as the
|
||||||
|
session read a secret, and whatever `auto` grant lay behind it would take over: the model's own
|
||||||
|
actions would have removed the owner's prohibition. Rule 7 rejects `/` because M3b mounts a grant
|
||||||
|
path at the same path inside the container, where `/` would replace the tool image itself. M3b
|
||||||
|
may reserve further prefixes for the same reason.
|
||||||
|
|
||||||
### Matching
|
### Matching
|
||||||
|
|
||||||
For a request to tool `T`, with the session's current taint `S`:
|
For a request to tool `T`, with the session's current taint `S`:
|
||||||
@@ -131,14 +140,32 @@ For a request to tool `T`, with the session's current taint `S`:
|
|||||||
1. Candidates are the grants whose `tool` is `T`.
|
1. Candidates are the grants whose `tool` is `T`.
|
||||||
2. A candidate is ruled out by the first of these that holds: it has expired (`now >= expires`);
|
2. A candidate is ruled out by the first of these that holds: it has expired (`now >= expires`);
|
||||||
`S > max_taint`; its constraints do not cover the arguments.
|
`S > max_taint`; its constraints do not cover the arguments.
|
||||||
3. Among those left, the most restrictive mode wins: `deny`, then `ask`, then `auto`. Ties go to
|
3. Among those left, the most restrictive mode wins: `deny`, then `ask`, then `auto`. Within that
|
||||||
the lowest grant id in byte order.
|
mode the winner is the grant with the longest matched path (section 3, "Paths"); grants with no
|
||||||
4. If none is left, the reason is `grant_expired` if some candidate was ruled out only by expiry;
|
matched path (`http_fetch`, and `shell` without `cwd`) all tie. Remaining ties go to the lowest
|
||||||
|
grant id in byte order. The winner is the grant that is recorded and, in M3b, the one whose
|
||||||
|
paths are mounted.
|
||||||
|
4. The result's label does not come from the winner alone. `result_class` is the highest among
|
||||||
|
all the grants left after step 2, and `untrusted` is true if any of them says so.
|
||||||
|
5. If none is left, the reason is `grant_expired` if some candidate was ruled out only by expiry;
|
||||||
otherwise `taint_too_high` if some candidate was ruled out only by taint; otherwise `no_grant`.
|
otherwise `taint_too_high` if some candidate was ruled out only by taint; otherwise `no_grant`.
|
||||||
|
|
||||||
"Only by expiry" means the grant would have matched had it not expired: taint and constraints both
|
"Only by expiry" means the grant would have matched had it not expired: taint and constraints both
|
||||||
pass. Likewise for taint.
|
pass. Likewise for taint.
|
||||||
|
|
||||||
|
Step 4 exists because grants overlap. With `a-home` (`paths = ["/home/kyle"]`, `result_class =
|
||||||
|
"private"`) and `b-keys` (`paths = ["/home/kyle/keys"]`, `result_class = "secret"`), both `auto`,
|
||||||
|
a read of `/home/kyle/keys/id` matches both. Whichever wins, the result is `secret`. The
|
||||||
|
longest-path rule then picks `b-keys`, so the mount is the narrower one.
|
||||||
|
|
||||||
|
Two consequences for whoever writes grants:
|
||||||
|
|
||||||
|
- An expired `deny` grant no longer denies. `expires` on a `deny` grant means "forbid this until
|
||||||
|
then".
|
||||||
|
- An `ask` grant with a lower `max_taint` than an `auto` grant over the same arguments drops out
|
||||||
|
when taint rises, and the call then runs without asking. Give the `ask` grant the higher
|
||||||
|
`max_taint`. Loading does not check this.
|
||||||
|
|
||||||
The outcome is one of: allowed by grant `g` (`auto`); ask, under grant `g`; denied by grant `g`
|
The outcome is one of: allowed by grant `g` (`auto`); ask, under grant `g`; denied by grant `g`
|
||||||
(`denied_by_grant`); denied with no grant (`no_grant`, `grant_expired`, `taint_too_high`).
|
(`denied_by_grant`); denied with no grant (`no_grant`, `grant_expired`, `taint_too_high`).
|
||||||
|
|
||||||
@@ -170,7 +197,7 @@ component. Paths are compared as they are, never normalised, and matching is by
|
|||||||
| `/home/kyle/notes` | `/home/kyle/notes/../.ssh/id` | `invalid_arguments` (`..`) |
|
| `/home/kyle/notes` | `/home/kyle/notes/../.ssh/id` | `invalid_arguments` (`..`) |
|
||||||
| `/home/kyle/notes` | `notes/a.md` | `invalid_arguments` (relative) |
|
| `/home/kyle/notes` | `notes/a.md` | `invalid_arguments` (relative) |
|
||||||
| `/home/kyle/notes` | `/home/kyle//notes/./a.md` | `invalid_arguments` (`//`, `.`) |
|
| `/home/kyle/notes` | `/home/kyle//notes/./a.md` | `invalid_arguments` (`//`, `.`) |
|
||||||
| `/` | `/etc/passwd` | inside (a grant of `/` covers everything; allowed, not advised) |
|
| `/` | anything | the grant file is invalid (loading rule 7) |
|
||||||
|
|
||||||
`brokerd` does not resolve symlinks. In M3b only the matched grant directory is mounted, at the
|
`brokerd` does not resolve symlinks. In M3b only the matched grant directory is mounted, at the
|
||||||
same path, so a symlink pointing outside it points at nothing inside the container.
|
same path, so a symlink pointing outside it points at nothing inside the container.
|
||||||
@@ -238,7 +265,9 @@ pub enum AuditEvent {
|
|||||||
post: Option<String>, // from M4, the Mattermost post id
|
post: Option<String>, // from M4, the Mattermost post id
|
||||||
reason: Option<String>, // the owner's text, for refusals
|
reason: Option<String>, // the owner's text, for refusals
|
||||||
outcome: DecisionRecord, // the re-decision (section 6); denied if refused or expired
|
outcome: DecisionRecord, // the re-decision (section 6); denied if refused or expired
|
||||||
},
|
grant: Option<String>, grant_sha256: Option<Hash32>, // as matched by the re-decision;
|
||||||
|
taint: DataClass, untrusted: bool, // the session's state at the re-decision
|
||||||
|
}, // for refused and expired: no grant, the state as it is
|
||||||
Result {
|
Result {
|
||||||
session: SessionId, call: CallId, decision: u64,
|
session: SessionId, call: CallId, decision: u64,
|
||||||
status: ResultStatus, // result | failed
|
status: ResultStatus, // result | failed
|
||||||
@@ -262,7 +291,9 @@ secret into the audit log. Arguments are recorded in full; they are at most one
|
|||||||
|
|
||||||
### The chain
|
### The chain
|
||||||
|
|
||||||
- A record is one line of JSON written by serde, then `\n`.
|
- A record is one line of JSON written by serde, then `\n`. The line and its `\n` go out in one
|
||||||
|
`write_all`, followed by the sync; whatever the record allows happens only after the sync
|
||||||
|
returns. A line without its newline was therefore never acted on.
|
||||||
- `hash(line)` is `proto::sha256` of the line's bytes without the `\n`.
|
- `hash(line)` is `proto::sha256` of the line's bytes without the `\n`.
|
||||||
- `prev` is the hash of the previous line; the first record ever written has `prev` all zeros and
|
- `prev` is the hash of the previous line; the first record ever written has `prev` all zeros and
|
||||||
`seq` 0.
|
`seq` 0.
|
||||||
@@ -270,6 +301,12 @@ secret into the audit log. Arguments are recorded in full; they are at most one
|
|||||||
- The file is `audit/YYYY-MM-DD.jsonl` by the UTC date of the record's `time`. The first record of
|
- The file is `audit/YYYY-MM-DD.jsonl` by the UTC date of the record's `time`. The first record of
|
||||||
a new day starts a new file and chains from the last line of the previous file. Files are created
|
a new day starts a new file and chains from the last line of the previous file. Files are created
|
||||||
with mode 0600; the directory is `fsync`ed after a file is created.
|
with mode 0600; the directory is `fsync`ed after a file is created.
|
||||||
|
- The writer never goes back. If a record's date is earlier than the latest file's (the clock was
|
||||||
|
stepped back over midnight), the record goes in the latest file. Files are verified in name
|
||||||
|
order, so a record appended to an older file would break the chain. The verifier reports the
|
||||||
|
backwards time as a clock warning.
|
||||||
|
- `Recovery` and `AcceptedBreak` records always go in the latest file, whatever their date: they
|
||||||
|
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 `flock` on `audit/.lock` for its whole life
|
||||||
and `fsync`s after every record.
|
and `fsync`s after every record.
|
||||||
|
|
||||||
@@ -307,8 +344,10 @@ pub struct ChainReport {
|
|||||||
pub failure: Option<ChainFailure>, // the first one: file, line (1-based), what
|
pub failure: Option<ChainFailure>, // the first one: file, line (1-based), what
|
||||||
pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
|
pub recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
|
||||||
pub abandoned: Vec<u64>, // seq of Ask decisions with no Approval after them
|
pub abandoned: Vec<u64>, // seq of Ask decisions with no Approval after them
|
||||||
|
pub unfinished: Vec<u64>, // seq of allowed decisions, and of approvals whose
|
||||||
|
// outcome is allowed, with no Result after them
|
||||||
pub clock_warnings: Vec<Location>, // time went backwards
|
pub clock_warnings: Vec<Location>, // time went backwards
|
||||||
pub torn_tail: Option<Location>, // last line of the last file has no newline
|
pub torn_tail: Option<Location>, // the last line of the last file needs recovery
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -316,33 +355,67 @@ A line fails if it does not parse as an `AuditRecord`, its `seq` is not the next
|
|||||||
`prev` is not the hash of the line before. Files are fed in name order; a file whose first record
|
`prev` is not the hash of the line before. Files are fed in name order; a file whose first record
|
||||||
does not chain from the previous file's last line fails at its line 1. Two cases are not failures:
|
does not chain from the previous file's last line fails at its line 1. Two cases are not failures:
|
||||||
|
|
||||||
- **Torn tail.** The last line of the last file has no newline. It is reported in `torn_tail`.
|
- **Torn tail.** The last line of the last file has no newline, or has one but does not parse (a
|
||||||
- **Recovered line.** A line that does not parse, immediately followed by a `Recovery` record whose
|
crash between ending a torn line and writing its `Recovery`). It is reported in `torn_tail` and
|
||||||
`torn_bytes` and `torn_sha256` describe exactly that line, and whose `prev` is the hash of the
|
is not checked further. Treating an unparseable last line this way gives up nothing: whoever
|
||||||
line before the torn one and whose `seq` follows that line's. Reported in `recoveries`.
|
could damage the last line could as easily remove its newline.
|
||||||
|
- **Recovered line.** A line immediately followed by a `Recovery` record whose `torn_bytes` and
|
||||||
|
`torn_sha256` describe exactly that line, whose `prev` is the hash of the line before it, and
|
||||||
|
whose `seq` follows that line's. "Immediately followed" spans files, though the writer never
|
||||||
|
produces that. **It makes no difference whether the recovered line parses**: a crash can cut a
|
||||||
|
record exactly before its newline, leaving complete JSON. A recovered line is not a record and
|
||||||
|
nothing may refer to its `seq`, which the `Recovery` record reuses. Reported in `recoveries`.
|
||||||
|
|
||||||
An `AcceptedBreak` record is accepted only if its `file` and `line` name the first failure found
|
The verifier therefore holds each line back until it has seen the next one, and judges it then (or
|
||||||
since the last accepted break (or the start). The verifier then clears that failure, reports the
|
at `finish`).
|
||||||
break, and continues with the break record as the new head. Lines between the failure and the
|
|
||||||
break record are not checked.
|
An `AcceptedBreak` record is accepted only if all of these hold:
|
||||||
|
|
||||||
|
- its `file` and `line` name the first failure found since the last accepted break (or the start);
|
||||||
|
- its `last_good` is the hash of the last line that verified before that failure (all zeros if
|
||||||
|
none did);
|
||||||
|
- its `prev` is the hash of the line immediately before the break record;
|
||||||
|
- its `seq` is the `seq` the failing line should have had, plus the number of lines from the
|
||||||
|
failure line up to the line before the break record (so a failure at line 7 with the break
|
||||||
|
record at line 10 adds 3). The count is of lines, not of what they say: a damaged
|
||||||
|
region must not choose the counter's value. After a deletion a later `seq` can therefore repeat
|
||||||
|
one inside the region; those lines are not vouched for, and a reference to a `seq` means the
|
||||||
|
latest record before it that has it.
|
||||||
|
|
||||||
|
The verifier then clears that failure, reports the break, and continues with the break record as
|
||||||
|
the new head. Lines between the failure and the break record are read only to look for the break
|
||||||
|
record and are not checked, so one break covers every failure before it.
|
||||||
|
|
||||||
|
A verifier started with `resume` has not seen the earlier files. When it meets an `AcceptedBreak`
|
||||||
|
whose `file` sorts before the first file it was given, it checks only `prev`, accepts it and
|
||||||
|
continues from it; the full verification judges the rest.
|
||||||
|
|
||||||
### Startup
|
### Startup
|
||||||
|
|
||||||
1. Take the lock. If it is held: print "brokerd is already running"
|
1. Take the lock. If it is held: print "brokerd is already running"
|
||||||
and `see docs/runbook.md#brokerd-already-running`, exit 1.
|
and `see docs/runbook.md#brokerd-already-running`, exit 1.
|
||||||
2. Verify the latest file, resumed from the last line of the file before it (if any). The earlier
|
2. Without `--accept-break`: verify the latest file, resumed from the last line of the file before
|
||||||
files are not re-read; `bxctl audit verify` does that.
|
it (if any). That line must parse as a record, or it is the failure. The earlier files are not
|
||||||
3. If the report has a torn tail: write `\n` after the torn bytes, then a `Recovery` record chained
|
re-read; `bxctl audit verify` does that. With `--accept-break`: verify every file, exactly as
|
||||||
from the line before the torn one. Print "audit: recovered a torn final line" and
|
`bxctl audit verify` does, because the break to accept must be the first failure of the whole
|
||||||
`see docs/runbook.md#audit-recovered`.
|
log, and it may lie in an older file that the short check never reads.
|
||||||
4. If the report has a failure: without `--accept-break`, print the failure's file, line and what,
|
3. If the report has a failure: without `--accept-break`, write nothing, print the failure's file,
|
||||||
and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`, append an
|
line and what, and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`,
|
||||||
`AcceptedBreak` record naming it, with `last_good` the hash of the last line that verified,
|
end a torn final line with `\n` if there is one, then append an `AcceptedBreak` record with the
|
||||||
`prev` the hash of the file's last line, and `seq` one more than the highest `seq` among lines
|
four values the verifier will check (above; lines are counted across files). Print what was
|
||||||
that parse (or than the failure line's predecessor if none after it parse). Print what was
|
|
||||||
accepted.
|
accepted.
|
||||||
|
4. Otherwise, if the report has a torn tail: write `\n` after the torn bytes if it is missing,
|
||||||
|
then a `Recovery` record chained from the line before the torn one. Print "audit: recovered a
|
||||||
|
torn final line" and `see docs/runbook.md#audit-recovered`.
|
||||||
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2.
|
5. `--accept-break` with no failure is an error: "nothing to accept". Exit 2.
|
||||||
|
|
||||||
|
An empty latest file (created, then a crash before its first record) is not a failure and needs
|
||||||
|
no recovery: the next record is its line 1 and chains from the file before.
|
||||||
|
|
||||||
|
One crash is not recovered automatically: a crash while the `Recovery` record itself is being
|
||||||
|
written leaves a line that no `Recovery` describes. That is a chain failure, and the runbook entry
|
||||||
|
says so. It takes two crashes within two consecutive one-line writes.
|
||||||
|
|
||||||
Nothing is truncated, rewritten or deleted, ever.
|
Nothing is truncated, rewritten or deleted, ever.
|
||||||
|
|
||||||
## 6. Sockets and messages
|
## 6. Sockets and messages
|
||||||
@@ -430,8 +503,9 @@ pub fn run(decision: Decision, runtime: &dyn Runtime) -> ToolResponse;
|
|||||||
```
|
```
|
||||||
|
|
||||||
`RunSpec` has public getters and no public constructor; a `compile_fail` doctest proves it, like
|
`RunSpec` has public getters and no public constructor; a `compile_fail` doctest proves it, like
|
||||||
`Decision`'s. `Decision` gains the parsed arguments and the matched grant (id, `result_class`,
|
`Decision`'s. `Decision` gains the parsed arguments, the winning grant (id, file hash, the matched
|
||||||
`untrusted`, the matched path or hosts), all set by `policy`.
|
path or hosts) and the result's label (`result_class` and `untrusted`, combined over every matching
|
||||||
|
grant as in section 3, step 4), all set by `policy`.
|
||||||
|
|
||||||
What `run` puts in the spec, from the decision:
|
What `run` puts in the spec, from the decision:
|
||||||
|
|
||||||
@@ -496,8 +570,11 @@ processes, output size) are M3b's.
|
|||||||
- `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
|
||||||
`audit: ok, <n> records, head <hex>`, then any recoveries, accepted breaks, abandoned approvals
|
`audit: ok, <n> records, head <hex>`, then any recoveries, accepted breaks, approvals "pending or
|
||||||
and clock warnings, one per line; or the failure as `<file>:<line>: <what>` and exit status 1.
|
abandoned", calls "running or unfinished" and clock warnings, one per line; or the failure as
|
||||||
|
`<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
|
||||||
|
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`: an `approval_pending` event prints the same two-line block as `approvals`. In the
|
||||||
interactive mode it then asks `approve 41? [y/N] ` on stderr and reads one line from stdin; `y`
|
interactive mode it then asks `approve 41? [y/N] ` on stderr and reads one line from stdin; `y`
|
||||||
sends `approve`, anything else sends `refuse`. `--say` and `--json` print the event only.
|
sends `approve`, anything else sends `refuse`. `--say` and `--json` print the event only.
|
||||||
@@ -539,12 +616,18 @@ first, as in M2.
|
|||||||
|
|
||||||
- **Policy tables.** Every row of every table in section 3 is a case, plus: mode precedence among
|
- **Policy tables.** Every row of every table in section 3 is a case, plus: mode precedence among
|
||||||
three matching grants; the reason order when one candidate is expired and another has too much
|
three matching grants; the reason order when one candidate is expired and another has too much
|
||||||
taint; expiry exactly at `expires`; ties by grant id.
|
taint; expiry exactly at `expires`; the longest matched path winning within a mode; ties by
|
||||||
|
grant id; the `a-home` and `b-keys` example labelled `secret` whichever id sorts first; an
|
||||||
|
`untrusted = true` grant among the matches setting the flag when the winner says false; an
|
||||||
|
expired `deny` no longer denying.
|
||||||
- **Loading.** One case per rule in "Loading", each with the problem text it must contain; one
|
- **Loading.** One case per rule in "Loading", each with the problem text it must contain; one
|
||||||
invalid file among valid ones denies a call a valid file would allow.
|
invalid file among valid ones denies a call a valid file would allow.
|
||||||
- **Property test.** A seeded xorshift generator (no crate) makes grant sets, session taints and
|
- **Property test.** A seeded xorshift generator (no crate) makes grant sets, session taints and
|
||||||
requests. A separate, deliberately naive oracle in the test file says what should happen; every
|
requests. A separate, deliberately naive oracle in the test file says what should happen; every
|
||||||
case must agree. Over sequences of calls: taint never goes down; every `Result` record follows a
|
case must agree. Two properties over single decisions, with restrictiveness ordered allowed,
|
||||||
|
ask, denied: adding a `deny` grant to a set never makes any outcome less restrictive; and a
|
||||||
|
call that is `denied_by_grant` at one taint is `denied_by_grant` at every higher taint. Over
|
||||||
|
sequences of calls: taint never goes down; every `Result` record follows a
|
||||||
`Decision` for the same call; the fake runtime sees a call only after `allowed` or an approval.
|
`Decision` for the same call; the fake runtime sees a call only after `allowed` or an approval.
|
||||||
The seed is printed on failure.
|
The seed is printed on failure.
|
||||||
- **Audit.** The writer: chain across a day boundary, `seq` across files, sync on each record, the
|
- **Audit.** The writer: chain across a day boundary, `seq` across files, sync on each record, the
|
||||||
@@ -552,8 +635,22 @@ first, as in M2.
|
|||||||
middle line; a deleted line; two lines swapped; a `seq` gap; a file that does not chain from the
|
middle line; a deleted line; two lines swapped; a `seq` gap; a file that does not chain from the
|
||||||
one before; a middle line cut short. Each must fail at the right file and line in both
|
one before; a middle line cut short. Each must fail at the right file and line in both
|
||||||
`ChainVerifier` and `brokerd`'s startup. Not failures: a torn tail (startup writes a `Recovery`,
|
`ChainVerifier` and `brokerd`'s startup. Not failures: a torn tail (startup writes a `Recovery`,
|
||||||
and the next verify reports it); an accepted break (reported by every later verify); an
|
and the next verify reports it); an accepted break (reported by every later verify). Failures:
|
||||||
`AcceptedBreak` naming the wrong line (a failure).
|
an `AcceptedBreak` naming the wrong line, or with the wrong `last_good`, `prev` or `seq`; a
|
||||||
|
`Recovery` whose hash or length does not match the line before it; a line no `Recovery`
|
||||||
|
describes followed by a torn `Recovery`.
|
||||||
|
- **Audit edges**, each a fixture or a scripted writer test: a torn tail that is complete JSON
|
||||||
|
lacking only its newline (recovered, and the chain verifies afterwards: this is the case a
|
||||||
|
"does not parse" rule gets wrong); an unparseable last line that has its newline (recovered
|
||||||
|
without a second newline); a torn first line of a file; an empty latest file; a clock stepped
|
||||||
|
back over midnight (the record goes in the latest file, one clock warning, the chain verifies);
|
||||||
|
a line torn on one day and recovered on the next (the `Recovery` is in the torn line's file); a
|
||||||
|
break in an older file (plain startup succeeds, `bxctl audit verify` fails, `--accept-break`
|
||||||
|
accepts it, both verify afterwards, and so does the next plain startup through `resume`); two
|
||||||
|
failures before one break record (one break covers both); a region holding a line with `seq`
|
||||||
|
18446744073709551615 (the break's `seq` is unaffected).
|
||||||
|
- **Unfinished and abandoned.** An allowed `Decision` with no `Result` is listed in `unfinished`;
|
||||||
|
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. A state writer that fails: `failed`, and the content is absent.
|
||||||
- **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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user