Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,21 @@ offline behavior, revocation, and audit recovery before enrolling developers.

## The audit record is evidence with explicit limits

Every tool call is appended to a BLAKE3-chained JSONL trail. Strict durability
Every attempted turn and every tool call is appended to a BLAKE3-chained JSONL
trail. A turn is recorded whether or not it called anything, so a session where
the model answered in text and used no tool still leaves a record. Turn entries
carry metadata only — outcome, prompt and response byte counts, provider,
model, token counts — and never the prompt or the answer. Strict durability
waits for the append to reach disk and refuses further non-idempotent work after
an audit failure. An anchor stored outside the trail detects deletion of its
tail, which the chain alone cannot detect.

One gap remains: a turn that Garrison's own admission gates refuse — a lapsed
seat, an unreachable plane, a full shipping backlog — is turned away before the
model loop is entered, and nothing appends it to the trail. Closing that gap
needs a public append path on the audit writer, which the agent runtime does
not expose yet.

```sh
garrison-agent audit verify
```
Expand Down
2 changes: 1 addition & 1 deletion agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ license.workspace = true
repository.workspace = true

[dependencies]
acton-ai = { version = "=0.35.0", default-features = false, features = ["fips", "sandbox-hardening", "derive", "otel", "ipc"] }
acton-ai = { version = "=0.36.0", default-features = false, features = ["fips", "sandbox-hardening", "derive", "otel", "ipc"] }
acton-reactive = "9.2.1"
acton-service-client = "0.1.2"
agent-client-protocol-schema = "1.6.0"
Expand Down
7 changes: 6 additions & 1 deletion agent/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,12 @@ fn configure_handlers(builder: &mut ManagedActor<Idle, TurnRouter>) {
next = actor.model.settle(Some(turn_id.clone()));
arm_expiry(actor, next.is_some());
}
TurnLifecycle::TurnRefused => {
// The refused turn's id is not read here on purpose. A turn
// acton-ai never admitted holds no claim to release, so there is
// nothing to settle *on*; settling on `None` simply lets the next
// waiter through. The id matters to the trail, which seals it,
// not to the router.
TurnLifecycle::TurnRefused { .. } => {
next = actor.model.settle(None);
arm_expiry(actor, next.is_some());
}
Expand Down
40 changes: 38 additions & 2 deletions agent/tests/audit_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
//! `regenerate_the_frozen_audit_fixture` in `audit.rs`, where the daemon
//! harness lives. Only for a break that has been decided on.

use acton_ai::audit::{parse_entries, verify_chain, ChainBreakKind};
use acton_ai::audit::{parse_entries, verify_chain, AuditEntryKind, ChainBreakKind};
use garrison_wire::audit::kind;

/// The trail as the daemon sealed it, byte for byte.
const FIXTURE: &str = include_str!("fixtures/audit-1.0/audit.jsonl");
Expand Down Expand Up @@ -86,7 +87,7 @@ fn an_edited_argument_breaks_the_chain_where_it_was_edited() {
let target = entries
.get_mut(1)
.expect("the fixture holds more than one entry");
target.arguments = serde_json::json!({ "path": "/edited/after/the/fact" });
target.arguments = Some(serde_json::json!({ "path": "/edited/after/the/fact" }));

let broken = verify_chain(&entries).expect_err("an edited entry must not verify");

Expand Down Expand Up @@ -115,3 +116,38 @@ fn dropping_the_last_entry_is_not_detectable_from_the_chain_alone() {
// hour was deleted. Only a copy the machine cannot reach can, by holding
// a head this one no longer matches. See `garrison_agent::shipping`.
}

#[test]
fn every_entry_the_1_0_daemon_wrote_still_reads_as_a_tool_call() {
// 1.1 added turn entries, distinguished by a discriminator that
// invocation entries omit. Omission is what kept the promise above: a
// field present on these lines would have changed their bytes, and their
// bytes are their hashes. So the rule "an entry that names no kind is a
// tool call" is not a convenience, it is the compatibility guarantee, and
// it is asserted here against the trail a 1.0 daemon actually wrote.
let entries = parse_entries(FIXTURE).expect("the frozen trail still parses");

for entry in &entries {
assert!(
entry.entry_kind.is_none(),
"a 1.0 entry names no kind: sequence {}",
entry.sequence,
);
assert_eq!(kind(entry), AuditEntryKind::Invocation);
assert!(
entry.tool_name.is_some(),
"a 1.0 entry is an invocation and names its tool: sequence {}",
entry.sequence,
);
assert!(
entry.turn_outcome.is_none(),
"a 1.0 entry carries none of the turn fields: sequence {}",
entry.sequence,
);
}

assert!(
!FIXTURE.contains("entry_kind"),
"the discriminator must not appear in bytes written before it existed",
);
}
19 changes: 16 additions & 3 deletions agent/tests/session_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,10 +530,23 @@ async fn a_conversation_survives_the_daemon_that_opened_it() {
trail.contains(&chain_head),
"the first daemon's head must still be in the chain the second extended",
);
// One tool call and one turn apiece. The turn entries are what make a
// prompt-only turn visible at all, so a count that only tallied tool calls
// would stop noticing if they ever went missing again.
let kinds: Vec<String> = trail
.lines()
.map(|line| {
let entry: Value = serde_json::from_str(line).expect("every trail line is an entry");
entry["entry_kind"]
.as_str()
.unwrap_or("invocation")
.to_string()
})
.collect();
assert_eq!(
trail.lines().count(),
2,
"one entry per tool call, across both daemons: {trail}",
kinds,
["invocation", "turn", "invocation", "turn"],
"one tool call and one turn per daemon, in the order they happened: {trail}",
);

drop(client);
Expand Down
17 changes: 16 additions & 1 deletion docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ holds a trail a real daemon wrote and fails if today's code disagrees with it
about a single byte. Regenerating that fixture is the visible cost of breaking
this promise, and it is deliberately awkward.

**Added in 1.1, without moving a byte:** the trail now also holds one entry per
attempted *turn*, not only per tool invocation. A turn entry is a new shape in
the same chain, distinguished by an `entry_kind` field that invocation entries
omit — and omission is the whole trick. Every field a turn entry adds, and the
discriminator itself, is absent from an invocation's serialized form and from
its hash pre-image, so a trail written by 1.0 still hashes to exactly what it
hashed to. `agent/tests/audit_fixture.rs` is what holds that claim honest: it
verifies a trail a 1.0 daemon wrote, unchanged.

A reader that does not know about turn entries will still verify the chain,
because verification is over the sealed bytes. It will simply see entries whose
tool fields are absent. Code that reads those fields must treat them as
optional; in this repo, `garrison_wire::audit::kind` is the one place that
decides which kind an entry is, and an entry that names no kind is a tool call.

Note what the chain does *not* promise, and never did: a prefix of a valid
chain is itself a valid chain, so truncation of the most recent entries is
undetectable from the file alone. That is why the trail ships off the box.
Expand All @@ -91,7 +106,7 @@ the next rebuild picks a change up silently.

| Crate | Requirement | Why |
| --- | --- | --- |
| `acton-ai` | `=0.35.0` (exact) | It is 0.x, and `garrison-wire` re-exports its audit types as Garrison's own wire contract. An unreviewed 0.36 would silently redefine what an audit entry is, which is surface 4 above. Exact makes the bump a reviewed commit. |
| `acton-ai` | `=0.36.0` (exact) | It is 0.x, and `garrison-wire` re-exports its audit types as Garrison's own wire contract. An unreviewed bump would silently redefine what an audit entry is, which is surface 4 above. Exact makes it a reviewed commit — as 0.35 → 0.36 was: it turned the tool fields optional to make room for turn entries, and the fixture test is what proved no existing byte moved. |
| `acton-service` | `0.39` | Plane-side only (`hooks-service`). For a 0.x crate, caret already caps below 0.40, so caret and tilde are the same requirement here. |
| `acton-service-client` | `0.1.2` | Ships inside the agent binary, so a fix here needs an agent release, not just a plane redeploy. |
| `acton-reactive` | `9.2.1` | Post-1.0 semver, patch float. |
Expand Down
33 changes: 32 additions & 1 deletion docs/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -891,7 +891,7 @@ cache buys availability, not integrity.
| Schema | What it answers |
|---|---|
| `AuditTrail` | What the install says about its own trail: local head, shipped through |
| `AuditEvent` | One entry from an install's BLAKE3 chain, the sealed line verbatim |
| `AuditEvent` | One entry from an install's BLAKE3 chain — a turn or a tool call — the sealed line verbatim |
| `AuditChain` | Where the plane has verified a trail's chain to, and whether it holds |

The agent keeps a hash-chained JSONL trail locally; acton-ai seals every entry
Expand All @@ -918,6 +918,37 @@ and may change; the verbatim entry may not. `session` is optional: a trail
belongs to an install, and an entry can be sealed before any session is known
to the plane. `trail` is required.

`kind` says which of two things an entry is, and the distinction matters more
than it looks. A `tool_call` row is one invocation: what ran, with what
arguments, decided by which gate. A `turn` row is one attempted model turn,
sealed whether or not that turn called anything. Without it the export answers
"what did this install run" and never "what did this install ask" — a session
where the model produced code and called no tool left no row at all, and a
compliance regime that specifies audit logging as *user activity* would have
been reading a trail that quietly only covered half of it.

A `turn` row carries metadata and no content: `prompt_bytes`,
`response_bytes`, `input_tokens`, `output_tokens`, `provider`, and `model`.
The byte counts answer the user/timestamp/activity/response-length question
without copying a prompt or an answer into a trail that leaves the workstation
and lands in a SIEM. There is no column for prompt text; adding one would be a
decision about retention rather than about auditing, and it is not made here.

`decision` and `decider` mean the approval gate on a `tool_call` row and the
*admission* gate on a `turn` row: a turn that ran was let through
(`auto_approved` / `default`) and a turn that did not was refused
(`forbidden` / `policy`), with the rendered reason in `justification`.
Admission is a gate in exactly the sense approval is, so a turn fills the same
columns rather than needing its own. A refused turn has no `outcome`, for the
same reason a denied call has none: it never ran. `sandboxed` is written
`false` on every turn row rather than inheriting the schema's `default(true)`,
because a turn confines nothing.

The ingest hook re-derives every one of those columns, turn columns included.
An install that could set its own `kind` could file a turn as a tool call and
vanish from a turn-level export; one that could set its own token counts could
under-report what it spent.

`AuditChain` is the plane's answer, one per trail rather than one per session.
Only the `audit_service` role writes it. The `before_validate` hook on
`AuditEvent`, bound in `config.toml` with `required = true`, loads the chain
Expand Down
12 changes: 12 additions & 0 deletions hooks-service/proto/audit_event_hooks.proto
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ message AuditEventBeforeValidateRequest {
optional string command_rule = 121;
optional string tool_rule = 122;
optional string detail = 123;
optional string provider = 124;
optional string model = 125;
optional int64 prompt_bytes = 126;
optional int64 response_bytes = 127;
optional int64 input_tokens = 128;
optional int64 output_tokens = 129;
}

message AuditEventBeforeValidateResponse {
Expand Down Expand Up @@ -66,5 +72,11 @@ message AuditEventBeforeValidateResponse {
optional string command_rule = 121;
optional string tool_rule = 122;
optional string detail = 123;
optional string provider = 124;
optional string model = 125;
optional int64 prompt_bytes = 126;
optional int64 response_bytes = 127;
optional int64 input_tokens = 128;
optional int64 output_tokens = 129;
}

Loading
Loading