feat(tools): the ingest credential — the event door, write-only

The agent token is deliberately barred from /v1/ingest: the events
are the inbound channel, and the model must not feed it itself. But
the same bar closes the door on every honest integration too — a
sidecar poller with events to hand in had to hold the human token,
which is wider than its job. This adds the missing class: a third
bearer, the ingest token, answering the event door and nothing else.

`ingest_allowed` is the whole credential in one function — one
route row, `POST /v1/ingest` — symmetric with `agent_allowed` and
suite-covered the same way: the mock and the daemon's real router
both answer the new checks (a push succeeds with the contract header;
a watch read and a consent answer are 403). The middleware classifier
gains the third arm, boot refuses an ingest token equal to either
other credential — one value playing two roles silently widens the
scoped one — and the daemon provisions it to tool shells as
`RHEMA_INGEST_TOKEN`, so a tool that only pushes events never sees
the wider agent token at all.

The door is the first half of the extensions story (rhema-tools spec
§8): a self-timed sidecar is now a tier-1 tool with a credential
narrow enough to hold.
This commit is contained in:
vhaudiquet committed 2026-10-24 01:17:43 +02:00
1 parent 98bc0fd623
commit f96f8c4c1d
10 files changed
+210 -18

No files matched your search

+61
View File
@@ -62,6 +62,14 @@ pub enum ConfigError {
"agent_token is the same value as token; the agent token must be its own secret, scoped to the tool routes"
)]
SameAgentToken,
/// The ingest credential is the same value as the human token or
/// the agent token: boot refuses, because the ingest credential's
/// safety is that it is write-only and the event door only — a
/// value shared with a wider class silently widens it.
#[error(
"ingest_token is the same value as another credential; the ingest token must be its own secret, write-only on the event door"
)]
IngestTokenCollision,
/// One watch is unusable; the daemon refuses to start rather than
/// poll a half-configured watch set.
#[error("watch #{index} ({forge} {repo}): {reason}")]
@@ -304,6 +312,14 @@ pub struct ClawConfig {
/// A directory of client-tool binaries (the rhema-tools project's)
/// layered onto the agent's shell PATH. Unset leaves PATH alone.
pub tools_bin: Option<PathBuf>,
/// The ingest credential: a third bearer class, handed to
/// integrations (a sidecar poller, an OS cron job) so they can hand
/// events to `/v1/ingest` — and do nothing else. Write-only by
/// construction: the middleware keeps it to the event door, and it
/// must differ from both other tokens (boot refuses otherwise).
/// Unset closes the door to credentials; the human token can still
/// ingest.
pub ingest_token: Option<String>,
/// What to watch — the optional seed for the persistent watch table.
/// Imported once, when the table is still empty; after that the
/// table rules, and watches are managed in chat or over HTTP.
@@ -337,6 +353,7 @@ impl Default for ClawConfig {
token: None,
agent_token: None,
tools_bin: None,
ingest_token: None,
transcript: None,
watches: Vec::new(),
calendars: Vec::new(),
@@ -471,6 +488,16 @@ impl ClawConfig {
return Err(ConfigError::SameAgentToken);
}
}
// The ingest credential's safety is that it is write-only and
// one route deep; a value shared with a wider class silently
// widens it, so boot refuses the collision with either token.
if let Some(ingest_token) = self.ingest_token.as_deref().filter(|t| !t.is_empty()) {
if Some(ingest_token) == self.token.as_deref()
|| Some(ingest_token) == self.agent_token.as_deref()
{
return Err(ConfigError::IngestTokenCollision);
}
}
for (index, watch) in self.watches.iter().enumerate() {
let fail = |reason: String| ConfigError::Watch {
index,
@@ -609,6 +636,40 @@ impl ClawConfig {
#[cfg(test)]
mod tests {
#[test]
fn the_ingest_token_must_differ_from_both_other_credentials() {
let config = ClawConfig {
token: Some("same".to_owned()),
ingest_token: Some("same".to_owned()),
..ClawConfig::default()
};
assert!(
matches!(config.validate(), Err(ConfigError::IngestTokenCollision)),
"an ingest credential sharing the human token is refused"
);
let config = ClawConfig {
token: Some("human".to_owned()),
agent_token: Some("same".to_owned()),
..config
};
assert!(
matches!(config.validate(), Err(ConfigError::IngestTokenCollision)),
"an ingest credential sharing the agent token is refused"
);
let config = ClawConfig {
agent_token: Some("agent".to_owned()),
ingest_token: Some("ingest".to_owned()),
..config
};
assert!(config.validate().is_ok(), "three distinct tokens pass");
// An empty ingest token is no token: no collision to refuse.
let blank = ClawConfig {
ingest_token: Some(String::new()),
..ClawConfig::default()
};
assert!(blank.validate().is_ok(), "a blank ingest token is unset");
}
#[test]
fn the_agent_token_must_differ_from_the_human_token() {
let config = ClawConfig {
+7
View File
@@ -242,6 +242,12 @@ impl Daemon {
);
tool_env.insert("RHEMA_TOKEN".to_owned(), agent_token.clone());
}
// The ingest credential rides its own variable: a tool asks
// for exactly the class it needs, and a tool that only pushes
// events never sees the wider agent token at all.
if let Some(ingest_token) = &config.ingest_token {
tool_env.insert("RHEMA_INGEST_TOKEN".to_owned(), ingest_token.clone());
}
if let Some(tools_bin) = &config.tools_bin {
let inherited = std::env::var("PATH").unwrap_or_default();
tool_env.insert(
@@ -399,6 +405,7 @@ impl Daemon {
phone,
config.token.clone(),
config.agent_token.clone(),
config.ingest_token.clone(),
);
Ok(Self {
+50 -8
View File
@@ -95,6 +95,9 @@ pub struct ClawState {
/// answering only the contract's data routes (see
/// [`require_auth`]).
agent_token: Option<String>,
/// The ingest credential integrations hold: a third bearer
/// answering the event door only (see [`require_auth`]).
ingest_token: Option<String>,
/// The generic sources `/v1/ingest` has registered on the
/// dispatcher, so a source name registers exactly once however many
/// events race in through it.
@@ -128,6 +131,7 @@ impl ClawState {
phone: Arc<crate::phone::PhoneStore>,
token: Option<String>,
agent_token: Option<String>,
ingest_token: Option<String>,
) -> Self {
Self {
dispatcher,
@@ -148,6 +152,7 @@ impl ClawState {
phone,
token,
agent_token,
ingest_token,
generic_sources: Arc::new(Mutex::new(BTreeSet::new())),
}
}
@@ -312,28 +317,39 @@ fn tokens_match(presented: &str, expected: &str) -> bool {
diff == 0
}
/// The bearer check, in two classes. With neither token configured the
/// The bearer check, in three classes. With no token configured the
/// surface is open, as before. The human token answers every `/v1`
/// route. The agent token — what the client tools hold — answers only
/// the data routes the tool contract allowlists, and only with the
/// contract header at the matching version: a tool cannot reach
/// consents, sessions, or ingest, so a leaked agent token cannot
/// answer the agent's own `ask_secret` (rhema-tools spec §5). Any
/// other bearer is a 401. `/healthz` never reaches this.
/// answer the agent's own `ask_secret` (rhema-tools spec §5). The
/// ingest credential — what integrations hold — answers the event
/// door and nothing else: write-only by construction, it can hand
/// events in and read nothing back. Any other bearer is a 401.
/// `/healthz` never reaches this.
async fn require_auth(State(state): State<ClawState>, request: Request, next: Next) -> Response {
let presented = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
let class = match (&state.token, &state.agent_token, presented) {
let class = match (
&state.token,
&state.agent_token,
&state.ingest_token,
presented,
) {
// Nothing configured: the open surface, bearers ignored.
(None, None, _) => Auth::Human,
(Some(expected), _, Some(presented)) if tokens_match(presented, expected) => Auth::Human,
(_, Some(expected), Some(presented)) if tokens_match(presented, expected) => Auth::Agent,
(None, None, None, _) => Auth::Human,
(Some(expected), _, _, Some(presented)) if tokens_match(presented, expected) => Auth::Human,
(_, Some(expected), _, Some(presented)) if tokens_match(presented, expected) => Auth::Agent,
(_, _, Some(expected), Some(presented)) if tokens_match(presented, expected) => {
Auth::Ingest
}
// A token configured and no bearer, or a bearer matching
// neither.
(_, _, _) => Auth::Invalid,
(_, _, _, _) => Auth::Invalid,
};
match class {
Auth::Human => next.run(request).await,
@@ -370,6 +386,32 @@ async fn require_auth(State(state): State<ClawState>, request: Request, next: Ne
.into_response()
}
}
Auth::Ingest => {
let version = request
.headers()
.get(CONTRACT_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == CONTRACT_VERSION.to_string());
if !version {
return (
StatusCode::CONFLICT,
Json(json!({"error": format!(
"ingest calls carry the {CONTRACT_HEADER} header at version {CONTRACT_VERSION}"
)})),
)
.into_response();
}
if rhema_tools_contract::ingest_allowed(request.method().as_str(), request.uri().path())
{
next.run(request).await
} else {
(
StatusCode::FORBIDDEN,
Json(json!({"error": "this route is not the ingest token's"})),
)
.into_response()
}
}
}
}
+1
View File
@@ -70,6 +70,7 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
agent_token: None,
tools_bin: None,
transcript: None,
ingest_token: None,
watches: vec![Watch {
forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(),
+1
View File
@@ -58,6 +58,7 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
agent_token: None,
tools_bin: None,
transcript: None,
ingest_token: None,
watches: vec![Watch {
forge: ForgeKind::GitHub,
repo: "acme/widgets".to_owned(),
+5
View File
@@ -63,6 +63,7 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig {
token: token.map(str::to_owned),
agent_token: None,
tools_bin: None,
ingest_token: None,
transcript: None,
watches: Vec::new(),
calendars: Vec::new(),
@@ -1200,6 +1201,7 @@ async fn the_surface_speaks_the_tool_contract() {
let dir = tempfile::tempdir().expect("tempdir");
let mut config = config(&dir, Some("human-token"));
config.agent_token = Some("agent-token".to_owned());
config.ingest_token = Some("ingest-token".to_owned());
let daemon = Daemon::build(
&config,
mock_registry(Arc::new(MockProvider::new(Vec::new()))),
@@ -1226,6 +1228,9 @@ async fn the_surface_speaks_the_tool_contract() {
rhema_tools_contract::Auth::Invalid => {
builder.header(reqwest::header::AUTHORIZATION, "Bearer a-stranger")
}
rhema_tools_contract::Auth::Ingest => {
builder.header(reqwest::header::AUTHORIZATION, "Bearer ingest-token")
}
};
let builder = match request.contract {
Some(version) => builder.header("x-rhema-contract", version.to_string()),