Skip to content
Open
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
25 changes: 25 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/agentkeys-daemon",
"crates/agentkeys-mcp",
"crates/agentkeys-mcp-server",
"crates/agentkeys-gate",
"crates/agentkeys-provisioner",
"crates/agentkeys-backend-client",
"crates/agentkeys-protocol",
Expand Down
75 changes: 75 additions & 0 deletions crates/agentkeys-core/src/audit/bodies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,38 @@ pub struct ConfigTeardownBody {
pub actor_target: String,
}

// ── 90..99 — gate family (Plan A in-path CustomLLM gate) ───────────────
//
// Emitted by `agentkeys-gate` once per LLM turn. The memory READS themselves
// are audited worker-side (#229, op_kind MemoryGet); this row records the
// turn-level control-plane facts: which engine answered, what was injected,
// and the per-namespace authorization outcome.

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GateTurnNamespaceBody {
/// Memory namespace the gate attempted to inject (e.g. `"travel"`).
pub namespace: String,
/// Outcome of the in-path cap-check + read: `"injected"`, `"empty"`, or
/// `"denied"` (a 403 the actor had no scope for — recorded, non-fatal).
pub outcome: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GateTurnBody {
/// The engine that produced the completion (`"openai-compatible"`,
/// `"echo"`, …) — provenance for "which model answered".
pub engine: String,
/// The upstream model id the turn ran against.
pub model: String,
/// Per-namespace cap-check + injection outcomes for this turn.
pub namespaces: Vec<GateTurnNamespaceBody>,
/// Total bytes of memory injected across all namespaces.
pub injected_bytes: u64,
/// Upstream-reported token usage (0 when the engine doesn't report it).
pub prompt_tokens: u64,
pub completion_tokens: u64,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -448,6 +480,49 @@ mod tests {
}
}

/// Gate family (Plan A): canonical CBOR roundtrip + typed decode for the
/// per-turn gate audit row, including the nested per-namespace outcomes.
#[test]
fn gate_family_cbor_roundtrip_and_typed_decode() {
use crate::audit::{envelope_for, AuditEnvelope, AuditOpKind, AuditResult, TypedAuditBody};

let body = GateTurnBody {
engine: "openai-compatible".into(),
model: "doubao-pro".into(),
namespaces: vec![
GateTurnNamespaceBody {
namespace: "personal".into(),
outcome: "injected".into(),
},
GateTurnNamespaceBody {
namespace: "travel".into(),
outcome: "denied".into(),
},
],
injected_bytes: 42,
prompt_tokens: 150,
completion_tokens: 30,
};
let env = envelope_for(
[0x44; 32],
[0x22; 32],
AuditOpKind::GateTurn,
body.clone(),
AuditResult::Success,
Some("where did I go in Chengdu?".to_string()),
None,
)
.unwrap();
let decoded =
AuditEnvelope::from_canonical_cbor(&env.to_canonical_cbor().unwrap()).unwrap();
assert_eq!(decoded.op_kind, AuditOpKind::GateTurn as u8);
assert_eq!(AuditOpKind::GateTurn.label(), "gate.turn");
match decoded.typed_body().unwrap() {
TypedAuditBody::GateTurn(b) => assert_eq!(b, body),
other => panic!("unexpected typed body: {other:?}"),
}
}

#[test]
fn payment_direct_body_uses_ref_as_field_name() {
// Sanity check: `ref` is a Rust reserved word, so the field is
Expand Down
8 changes: 5 additions & 3 deletions crates/agentkeys-core/src/audit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ use thiserror::Error;
pub use bodies::{
ConfigGetBody, ConfigPutBody, ConfigTeardownBody, CredFetchBody, CredStoreBody,
CredTeardownBody, DeviceAddBody, DeviceRevokeBody, EmailReceiveBody, EmailSendBody,
K10RotateBody, K3EpochAdvanceBody, MemoryGetBody, MemoryPutBody, MemoryTeardownBody,
PaymentDirectBody, PaymentEscrowRedeemBody, ScopeGrantBody, ScopeRevokeBody, SignEip191Body,
SignEip712Body,
GateTurnBody, GateTurnNamespaceBody, K10RotateBody, K3EpochAdvanceBody, MemoryGetBody,
MemoryPutBody, MemoryTeardownBody, PaymentDirectBody, PaymentEscrowRedeemBody, ScopeGrantBody,
ScopeRevokeBody, SignEip191Body, SignEip712Body,
};
pub use op_kind::AuditOpKind;

Expand Down Expand Up @@ -238,6 +238,7 @@ pub enum TypedAuditBody {
ConfigPut(ConfigPutBody),
ConfigGet(ConfigGetBody),
ConfigTeardown(ConfigTeardownBody),
GateTurn(GateTurnBody),
}

impl TypedAuditBody {
Expand Down Expand Up @@ -277,6 +278,7 @@ impl TypedAuditBody {
AuditOpKind::ConfigTeardown => {
Self::ConfigTeardown(serde_json::from_value(value).ok()?)
}
AuditOpKind::GateTurn => Self::GateTurn(serde_json::from_value(value).ok()?),
})
}
}
Expand Down
14 changes: 12 additions & 2 deletions crates/agentkeys-core/src/audit/op_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
//! - 60-69 email family (EmailSend=60, EmailReceive=61; 62-69 reserved)
//! - 70-79 K3 family (K3EpochAdvance=70; 71-79 reserved)
//! - 80-89 config family (ConfigPut=80, ConfigGet=81, ConfigTeardown=82; 83-89 reserved)
//! - 90-255 reserved for future families
//! - 90-99 gate family (GateTurn=90; 91-99 reserved)
//! - 100-255 reserved for future families

/// Canonical op_kind enum. The byte value MUST match the row in arch.md
/// §15.3a. The enum is `repr(u8)` so `as u8` gives the canonical byte.
Expand Down Expand Up @@ -47,6 +48,11 @@ pub enum AuditOpKind {
ConfigPut = 80,
ConfigGet = 81,
ConfigTeardown = 82,
/// In-path CustomLLM gate turn (Plan A, `docs/plan/ai-device-platform.md`
/// §3): one cap-checked + memory-injected + audited LLM turn. Recorded by
/// `agentkeys-gate` so a gated turn is on the ledger even when it injected
/// no memory (the worker-side memory audit only covers turns that read).
GateTurn = 90,
}

impl AuditOpKind {
Expand Down Expand Up @@ -75,6 +81,7 @@ impl AuditOpKind {
80 => Self::ConfigPut,
81 => Self::ConfigGet,
82 => Self::ConfigTeardown,
90 => Self::GateTurn,
_ => return None,
})
}
Expand Down Expand Up @@ -105,6 +112,7 @@ impl AuditOpKind {
Self::ConfigPut => "config.put",
Self::ConfigGet => "config.get",
Self::ConfigTeardown => "config.teardown",
Self::GateTurn => "gate.turn",
}
}
}
Expand Down Expand Up @@ -140,6 +148,7 @@ mod tests {
AuditOpKind::ConfigPut,
AuditOpKind::ConfigGet,
AuditOpKind::ConfigTeardown,
AuditOpKind::GateTurn,
];
for k in all {
let byte = k as u8;
Expand All @@ -156,7 +165,7 @@ mod tests {
#[test]
fn unknown_bytes_return_none() {
for byte in [
3u8, 9, 13, 19, 22, 32, 42, 53, 62, 71, 83, 89, 90, 200, 250, 255,
3u8, 9, 13, 19, 22, 32, 42, 53, 62, 71, 83, 89, 91, 99, 200, 250, 255,
] {
assert_eq!(
AuditOpKind::from_u8(byte),
Expand Down Expand Up @@ -193,6 +202,7 @@ mod tests {
AuditOpKind::ConfigPut as u8,
AuditOpKind::ConfigGet as u8,
AuditOpKind::ConfigTeardown as u8,
AuditOpKind::GateTurn as u8,
];
let s: HashSet<_> = all.iter().copied().collect();
assert_eq!(s.len(), all.len(), "duplicate byte assignment");
Expand Down
53 changes: 53 additions & 0 deletions crates/agentkeys-gate/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[package]
name = "agentkeys-gate"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "agentkeys-gate"
path = "src/main.rs"

[lib]
name = "agentkeys_gate"
path = "src/lib.rs"

[dependencies]
# The shipped broker/worker chain (cap-mint -> per-actor STS -> worker put/get ->
# audit). The gate is a THIN in-path wrapper over this; it never re-types a
# cap/worker body (issue #203 — one owner).
agentkeys-backend-client = { workspace = true }
# Wire helpers shared with the browser host: service_memory(), normalize_omni_0x().
agentkeys-protocol = { workspace = true }
# Caller-side memory selection engine (Passthrough / Lexical) — deterministic,
# no LLM in the gate path. The injection seam (plan §6a).
agentkeys-memory-engine = { workspace = true }
# Canonical audit op_kind table (AuditOpKind) + K10 device-key load for cap-PoP.
agentkeys-core = { workspace = true }

serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }

axum = { version = "0.7", features = ["json"] }
tower = "0.4"
# reqwest pins rustls-tls so the upstream LLM call uses the same crypto provider
# the broker host already ships; the `ring` provider is installed in main.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
clap = { version = "4", features = ["derive", "env"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22"

[dev-dependencies]
tokio = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
# AuditOpKind::GateTurn assertions in the gate-flow tests.
agentkeys-core = { workspace = true }
base64 = "0.22"
tower = { version = "0.4", features = ["util"] }
http-body-util = "0.1"
Loading
Loading