feat(tools): the contract crate — allowlist, header, conformance suite

The client tools need one shared definition of what a tool may do,
and the daemon needs to enforce exactly that definition, not a
hand-copied copy of it. This crate is that definition, pure: no HTTP
client, no process — the allowlist as data, the version header as
constants, the request/response and error types, and the conformance
suite that drives any transport through the checks the contract
promises (the bearer gate, the version handshake, the allowlist's
denials, round-trips through every allowed route, one error shape).

`agent_allowed` is the whole security surface in one function:
method and path segments with `*` and `**`, exact otherwise — a
daemon's middleware asks it and nothing else, so the claw's scoping
and the tools' expectations cannot drift apart. The suite runs
against a mock router in this crate's tests and against the daemon's
real axum router in the claw's; same results is the definition of
implementing the contract.
This commit is contained in:
vhaudiquet committed 2026-10-24 01:02:28 +02:00
1 parent fb05983b05
commit 75585f2dd3
5 files changed
+950

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
//! The wire contract between the rhema claw daemon and its thin client
//! tools — the entire knowledge the tools project may have of the claw.
//!
//! The claw's model-facing data tools are executables the agent runs in
//! its shell: parse arguments, make one HTTP call, print JSON, exit.
//! The daemon keeps the state; the clients hold nothing. What binds the
//! two sides without binding either to the other's crate is this
//! contract: the routes, the JSON shapes, the agent token's allowlist,
//! and the conformance suite both sides answer to. The dependency
//! arrows run claw → contract ← tools and never cross.
//!
//! One decision shapes everything else: the surface reuses the claw's
//! existing `/v1` HTTP API — the same routes the web UI and the phone
//! client speak — so promoting a tool to a route serves the other
//! clients too. The agent token (a second, scoped bearer) is what makes
//! handing the model a credential safe: it answers only the data routes
//! in [`agent_allowed`], never consents, sessions, or ingest, so a tool
//! cannot answer its own `ask_secret`.
use serde::{Deserialize, Serialize};
/// The request header carrying the contract version a client speaks.
pub const CONTRACT_HEADER: &str = "x-rhema-contract";
/// The contract version this crate speaks. A daemon answering at a
/// different version rejects the request with `409` and the standard
/// error shape, so a stale installed binary fails loudly on first use.
pub const CONTRACT_VERSION: u32 = 1;
/// The notifications collection: `GET` lists the queue, `POST` pushes
/// one row a device delivers at its next poll-and-ack.
pub const NOTIFICATIONS: &str = "/v1/notifications";
/// The scheduled tasks: `GET` lists, `POST` schedules, and
/// `DELETE /v1/tasks/{id}` cancels.
pub const TASKS: &str = "/v1/tasks";
/// The forge watch table: `GET` lists, `POST` adds, and
/// `DELETE /v1/watches/{forge}/{repo}` removes.
pub const WATCHES: &str = "/v1/watches";
/// The route allowlist the agent token answers for. The claw's auth
/// middleware and the mock router both run this — one list, so the
/// daemon's enforcement and the contract's conformance suite cannot
/// drift. `path` is the daemon-side path under which the routes nest.
///
/// Reads and writes over the data domains the clients serve: watches,
/// notifications, tasks. Everything else — consent answers, session
/// creation, ingest, phone or account removal — is human-token
/// territory: an agent that could answer consents could answer its own
/// `ask_secret`.
pub fn agent_allowed(method: &str, path: &str) -> bool {
/// One allowlist row: the method and the path segments, `*` matching
/// one segment and `**` matching the rest.
const ROUTES: &[(&str, &[&str])] = &[
("GET", &["v1", "watches"]),
("POST", &["v1", "watches"]),
("DELETE", &["v1", "watches", "*", "**"]),
("GET", &["v1", "notifications"]),
("POST", &["v1", "notifications"]),
("GET", &["v1", "tasks"]),
("POST", &["v1", "tasks"]),
("DELETE", &["v1", "tasks", "*"]),
];
// The routes are daemon-side (`/v1/...`), but a daemon whose auth
// middleware runs inside its `/v1` nest sees the stripped path —
// accept both spellings, one allowlist.
let prefixed = format!("/v1{path}");
let path = if path.starts_with("/v1") {
path
} else {
prefixed.as_str()
};
let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
ROUTES
.iter()
.any(|(route_method, pattern)| *route_method == method && match_pattern(pattern, &segments))
}
/// Matches one allowlist pattern against a request's path segments.
fn match_pattern(pattern: &[&str], segments: &[&str]) -> bool {
match (pattern.first(), segments.first()) {
(None, None) => true,
(Some(&"**"), _) => true,
(Some(p), Some(s)) if *p == "*" || p == s => match_pattern(&pattern[1..], &segments[1..]),
_ => false,
}
}
/// The body of a `POST /v1/notifications` push. The device picks which
/// registered device polls the row; `None` means the default device.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotificationPush {
/// The notification's title line.
pub title: String,
/// The notification's body text.
pub body: String,
/// The device to deliver to; the daemon's default device when
/// `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
}
/// One notification row as the daemon returns it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NotificationOut {
/// The row id.
pub id: i64,
/// The device the row waits for.
pub device: String,
/// The title line.
pub title: String,
/// The body text.
pub body: String,
/// Creation time, milliseconds since the Unix epoch.
pub created_at: i64,
/// Delivery time, milliseconds since the Unix epoch, once the
/// device acked; `None` while outstanding.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivered_at: Option<i64>,
}
/// The body of a `POST /v1/tasks` schedule. `kind` picks the shape:
/// `at` requires `at_millis`, `every` requires `interval_millis`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskAdd {
/// `at` for a one-shot, `every` for a recurring task.
pub kind: String,
/// The prompt the firing turn runs.
pub prompt: String,
/// The one-shot's firing time, milliseconds since the Unix epoch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_millis: Option<i64>,
/// The recurring task's interval, in milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_millis: Option<i64>,
/// The session a firing wakes; the task's own `task:<id>` slot when
/// `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deliver_to: Option<String>,
}
/// One task row as the daemon returns it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskOut {
/// The row id; `DELETE /v1/tasks/{id}` addresses it.
pub id: i64,
/// The prompt the scheduled turn runs.
pub prompt: String,
/// `at` or `every`.
pub kind: String,
/// The one-shot's firing time, milliseconds since the Unix epoch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_millis: Option<i64>,
/// The recurring task's interval, in milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_millis: Option<i64>,
/// When the task next fires, milliseconds since the Unix epoch.
pub next_due_millis: i64,
/// Creation time, milliseconds since the Unix epoch.
pub created_at: i64,
/// The session a firing wakes; `None` meaning the task's own slot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deliver_to: Option<String>,
}
/// The removal answer both watches and tasks speak: `{"removed": true}`
/// even when the row was already gone — a replay after a crash
/// converges instead of 404ing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Removed {
/// Whether a row was removed by this call.
pub removed: bool,
}
/// The one error shape every failure speaks: the last line of stdout is
/// `{"error": "..."}` and the exit code says the class.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiError {
/// What went wrong, in one line a model can react to.
pub error: String,
}
/// Which bearer a conformance call presents. The transport resolves a
/// class to the actual token it holds; the suite never spells tokens
/// into request bodies.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Auth {
/// No `Authorization` header at all.
None,
/// The human token: the full surface.
Human,
/// The agent token: the [`agent_allowed`] routes only.
Agent,
/// A bearer that matches neither configured token.
Invalid,
}
/// One conformance request.
#[derive(Debug, Clone)]
pub struct CallRequest {
/// The HTTP method.
pub method: &'static str,
/// The daemon-side path, e.g. `/v1/tasks`.
pub path: String,
/// Which bearer to present.
pub auth: Auth,
/// The `x-rhema-contract` value; `None` sends no header.
pub contract: Option<u32>,
/// The JSON body, when the route takes one.
pub body: Option<serde_json::Value>,
}
/// One conformance response.
#[derive(Debug, Clone)]
pub struct CallResponse {
/// The HTTP status.
pub status: u16,
/// The JSON body; `{}` when the route answers empty.
pub body: serde_json::Value,
}
mod conformance;
pub use conformance::conformance;
mod mock;
pub use mock::MockClaw;
/// Milliseconds since the Unix epoch — the timestamp vocabulary every
/// contract shape speaks.
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}