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:
@@ -116,14 +116,23 @@ gives one, the line and field.
|
||||
| `shell` | optional | must be empty |
|
||||
| `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").
|
||||
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
|
||||
`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
|
||||
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
|
||||
|
||||
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`.
|
||||
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.
|
||||
3. Among those left, the most restrictive mode wins: `deny`, then `ask`, then `auto`. Ties go to
|
||||
the lowest grant id in byte order.
|
||||
4. If none is left, the reason is `grant_expired` if some candidate was ruled out only by expiry;
|
||||
3. Among those left, the most restrictive mode wins: `deny`, then `ask`, then `auto`. Within that
|
||||
mode the winner is the grant with the longest matched path (section 3, "Paths"); grants with no
|
||||
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`.
|
||||
|
||||
"Only by expiry" means the grant would have matched had it not expired: taint and constraints both
|
||||
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`
|
||||
(`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` | `notes/a.md` | `invalid_arguments` (relative) |
|
||||
| `/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
|
||||
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
|
||||
reason: Option<String>, // the owner's text, for refusals
|
||||
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 {
|
||||
session: SessionId, call: CallId, decision: u64,
|
||||
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
|
||||
|
||||
- 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`.
|
||||
- `prev` is the hash of the previous line; the first record ever written has `prev` all zeros and
|
||||
`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
|
||||
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.
|
||||
- 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
|
||||
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 recoveries: Vec<Location>, pub accepted_breaks: Vec<Location>,
|
||||
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 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
|
||||
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`.
|
||||
- **Recovered line.** A line that does not parse, immediately followed by a `Recovery` record whose
|
||||
`torn_bytes` and `torn_sha256` describe exactly that line, and whose `prev` is the hash of the
|
||||
line before the torn one and whose `seq` follows that line's. Reported in `recoveries`.
|
||||
- **Torn tail.** The last line of the last file has no newline, or has one but does not parse (a
|
||||
crash between ending a torn line and writing its `Recovery`). It is reported in `torn_tail` and
|
||||
is not checked further. Treating an unparseable last line this way gives up nothing: whoever
|
||||
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
|
||||
since the last accepted break (or the start). 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 not checked.
|
||||
The verifier therefore holds each line back until it has seen the next one, and judges it then (or
|
||||
at `finish`).
|
||||
|
||||
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
|
||||
|
||||
1. Take the lock. If it is held: print "brokerd is already running"
|
||||
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
|
||||
files are not re-read; `bxctl audit verify` does that.
|
||||
3. If the report has a torn tail: write `\n` after the torn bytes, 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`.
|
||||
4. If the report has a failure: without `--accept-break`, print the failure's file, line and what,
|
||||
and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`, append an
|
||||
`AcceptedBreak` record naming it, with `last_good` the hash of the last line that verified,
|
||||
`prev` the hash of the file's last line, and `seq` one more than the highest `seq` among lines
|
||||
that parse (or than the failure line's predecessor if none after it parse). Print what was
|
||||
2. Without `--accept-break`: verify the latest file, resumed from the last line of the file before
|
||||
it (if any). That line must parse as a record, or it is the failure. The earlier files are not
|
||||
re-read; `bxctl audit verify` does that. With `--accept-break`: verify every file, exactly as
|
||||
`bxctl audit verify` does, because the break to accept must be the first failure of the whole
|
||||
log, and it may lie in an older file that the short check never reads.
|
||||
3. If the report has a failure: without `--accept-break`, write nothing, print the failure's file,
|
||||
line and what, and `see docs/runbook.md#audit-chain-broken`, and exit 1. With `--accept-break`,
|
||||
end a torn final line with `\n` if there is one, then append an `AcceptedBreak` record with the
|
||||
four values the verifier will check (above; lines are counted across files). Print what was
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
`Decision`'s. `Decision` gains the parsed arguments and the matched grant (id, `result_class`,
|
||||
`untrusted`, the matched path or hosts), all set by `policy`.
|
||||
`Decision`'s. `Decision` gains the parsed arguments, the winning grant (id, file hash, the matched
|
||||
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:
|
||||
|
||||
@@ -496,8 +570,11 @@ processes, output size) are M3b's.
|
||||
- `bxctl grants check` prints each problem as `<file>:<line>: <problem>`, or `grants: ok`; exit
|
||||
status 1 if there are problems.
|
||||
- `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
|
||||
and clock warnings, one per line; or the failure as `<file>:<line>: <what>` and exit status 1.
|
||||
`audit: ok, <n> records, head <hex>`, then any recoveries, accepted breaks, approvals "pending or
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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.
|
||||
The seed is printed on failure.
|
||||
- **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
|
||||
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`,
|
||||
and the next verify reports it); an accepted break (reported by every later verify); an
|
||||
`AcceptedBreak` naming the wrong line (a failure).
|
||||
and the next verify reports it); an accepted break (reported by every later verify). Failures:
|
||||
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
|
||||
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`.
|
||||
|
||||
Reference in New Issue
Block a user