haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! Where `haematite mcp` takes the Lys pass it presents as `session.pass`: a file
//! someone else keeps renewed, or Lys's agent pass route asked directly.
//!
//! Nothing here runs on a timer. Before a call the held pass is checked
//! against the clock and, when there is none or it expires within
//! [`RENEW_WITHIN_SECS`], Lys is asked for a new one there and then. A store
//! that refuses the presented pass as expired or invalid gets exactly one
//! renewal and one second presentation, never more.
//!
//! The grant credential is read from its file only when a pass is asked for,
//! is sent only in the `lys-grant-token` header (marked sensitive), and is
//! never written into a refusal, a diagnostic or a log; neither is the pass.

use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::Deserialize;
use ureq::http::{HeaderValue, Uri, header};

use super::adapter::ServiceRefusal;

/// A held pass expiring within this many seconds is renewed before use.
pub(super) const RENEW_WITHIN_SECS: u64 = 60;
/// The audience every pass asked for here names.
const AUDIENCE: &str = "haematite";
/// The request body of the agent pass route.
const REQUEST: &[u8] = br#"{"audience":"haematite"}"#;
/// The header carrying the grant credential.
const GRANT_HEADER: &str = "lys-grant-token";
/// The header carrying the agent's signed request.
const SIGNATURE_HEADER: &str = "lys-agent-signature";
/// The header carrying the run pass of an agent Lys launched.
const RUN_PASS_HEADER: &str = "lys-agent-pass";
/// The longest environment variable name taken for a run pass.
const RUN_PASS_NAME_BYTES: usize = 64;
/// The most bytes of a Lys answer read, accepted or refused.
const ANSWER_BYTES: u64 = 65_536;
/// The longest Lys refusal name repeated in a tool's refusal.
const REFUSAL_NAME_BYTES: usize = 96;
/// The longest agent id taken on the command line.
const AGENT_ID_BYTES: usize = 128;

/// Store refusals of a presented pass that a fresh pass can answer.
const RENEWABLE: &[&str] = &[
    "pass_expired",
    "signature_refused",
    "unpublished_key",
    "pass_encoding_refused",
];

/// The source of the pass presented before each tool's verb.
pub(super) enum PassSource {
    /// A file holding a pass, read afresh on every call.
    File(PathBuf),
    /// Lys's agent pass route, asked whenever the held pass is near expiry.
    Lys(LysAgent),
    /// The pass the HTTP MCP's caller presented as its bearer sign-in: its
    /// own Lys pass for haematite, presented as it is, so the store judges
    /// the caller's own grants. Renewing it is the caller's.
    Held(String),
}

impl PassSource {
    /// Takes the pass to present and hands it to `present`, which presents
    /// it to the store. For a Lys source a renewable store refusal is
    /// answered by one renewal and one more presentation.
    pub(super) fn present(
        &mut self,
        mut present: impl FnMut(&str) -> Result<(), ServiceRefusal>,
    ) -> Result<(), ServiceRefusal> {
        match self {
            Self::File(path) => present(read_pass_file(path)?.trim()),
            Self::Held(pass) => present(pass),
            Self::Lys(agent) => {
                let first = present(agent.current()?);
                match first {
                    Err(store) if RENEWABLE.contains(&store.code.as_str()) => {
                        let second = present(agent.renew()?);
                        if second.is_err() {
                            // A pass refused twice is not kept: the next call
                            // asks Lys before presenting anything.
                            agent.held = None;
                        }
                        second
                    }
                    other => other,
                }
            }
        }
    }
}

fn read_pass_file(path: &Path) -> Result<String, ServiceRefusal> {
    std::fs::read_to_string(path).map_err(|error| {
        refusal(
            "mcp_pass_unreadable",
            format!("the pass file could not be read: {error}"),
        )
    })
}

/// A pass Lys answered and the instant it stops being valid.
struct Held {
    pass: String,
    expires_at: u64,
}

/// How the agent proves itself to Lys's pass route.
pub(super) enum Proof {
    /// A grant credential file, sent in the `lys-grant-token` header.
    Grant(PathBuf),
    /// The agent's own key (a PEM file, or a seat file holding
    /// `privateKeyPem`), signing the request in `lys-agent-signature`.
    Key(PathBuf),
    /// The run pass of an agent Lys launched, read from the named
    /// environment variable when a pass is asked for and sent in the
    /// `lys-agent-pass` header.
    RunPass(String),
}

impl Proof {
    fn file(&self) -> Option<&Path> {
        match self {
            Self::Grant(path) | Self::Key(path) => Some(path),
            Self::RunPass(_) => None,
        }
    }
}

/// Asks Lys's agent pass route for a haematite pass for one agent.
pub(super) struct LysAgent {
    client: ureq::Agent,
    /// `{issuer}/api/agents/{agent}/pass`.
    route: String,
    /// The agent id, the first word of a signed request.
    agent: String,
    /// The route's path, the one a signed request names.
    path: String,
    proof: Proof,
    held: Option<Held>,
    /// Seconds since the epoch; the system clock outside tests.
    clock: Box<dyn Fn() -> u64 + Send>,
    /// Reads an environment variable; the process environment outside tests.
    environment: Environment,
}

/// Reads one environment variable by name.
type Environment = Box<dyn Fn(&str) -> Option<String> + Send>;

#[derive(Deserialize)]
struct Answer {
    pass: String,
    expires_at: u64,
    audience: String,
}

#[derive(Deserialize)]
struct Refused {
    refusal: String,
}

impl LysAgent {
    /// An agent pass source for `agent` at `issuer`, every request bounded
    /// by `timeout`. Refuses, by a fixed sentence, an issuer that is not an
    /// https origin (or http on 127.0.0.1 or localhost), an agent id that is
    /// not plain path text, and a credential or key path that is not absolute.
    pub(super) fn new(
        issuer: &str,
        agent: &str,
        proof: Proof,
        timeout: Duration,
    ) -> Result<Self, &'static str> {
        let origin = origin(issuer).ok_or(
            "lys agent requires an https issuer origin, or http on 127.0.0.1 or localhost",
        )?;
        if agent.is_empty()
            || agent.len() > AGENT_ID_BYTES
            || !agent
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        {
            return Err("lys agent requires a plain agent id");
        }
        if proof.file().is_some_and(|file| !file.is_absolute()) {
            return Err("credential or key file requires an absolute path");
        }
        if let Proof::RunPass(name) = &proof
            && (name.is_empty()
                || name.len() > RUN_PASS_NAME_BYTES
                || !name
                    .bytes()
                    .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
        {
            return Err("run pass requires an upper-case environment variable name");
        }
        let config = ureq::Agent::config_builder()
            .proxy(None)
            .http_status_as_error(false)
            .max_redirects(0)
            .max_redirects_will_error(false)
            .max_idle_connections(0)
            .timeout_global(Some(timeout))
            .timeout_resolve(Some(timeout))
            .build();
        Ok(Self {
            client: ureq::Agent::new_with_config(config),
            route: format!("{origin}/api/agents/{agent}/pass"),
            agent: agent.to_owned(),
            path: format!("/api/agents/{agent}/pass"),
            proof,
            held: None,
            clock: Box::new(system_now),
            environment: Box::new(|name| std::env::var(name).ok()),
        })
    }

    /// The route this source asks.
    #[cfg(test)]
    pub(super) fn route(&self) -> &str {
        &self.route
    }

    /// The file the grant credential or the agent key is read from.
    #[cfg(test)]
    pub(super) fn credential_file(&self) -> Option<&Path> {
        self.proof.file()
    }

    /// A pass for haematite from Lys, asked now, for a caller that presents
    /// it itself (an MCP client's bearer sign-in).
    pub(super) fn ask_now(&mut self) -> Result<&str, ServiceRefusal> {
        self.renew()
    }

    /// The held pass when it is good for more than [`RENEW_WITHIN_SECS`],
    /// otherwise a new one from Lys.
    fn current(&mut self) -> Result<&str, ServiceRefusal> {
        let renew_after = (self.clock)().saturating_add(RENEW_WITHIN_SECS);
        let held = match self.held.take() {
            Some(held) if held.expires_at > renew_after => held,
            _ => self.ask()?,
        };
        Ok(&self.held.insert(held).pass)
    }

    /// Asks Lys for a new pass, whatever is held, and holds it.
    fn renew(&mut self) -> Result<&str, ServiceRefusal> {
        self.held = None;
        let held = self.ask()?;
        Ok(&self.held.insert(held).pass)
    }

    fn ask(&self) -> Result<Held, ServiceRefusal> {
        let (name, proof) = match &self.proof {
            Proof::Grant(_) => (GRANT_HEADER, self.credential()?),
            Proof::RunPass(name) => (RUN_PASS_HEADER, self.run_pass(name)?),
            Proof::Key(path) => {
                let key = super::lys_sign::read_key(path)?;
                let millis = (self.clock)().saturating_mul(1000);
                let signed =
                    super::lys_sign::signature(&key, &self.agent, &self.path, REQUEST, millis)?;
                (SIGNATURE_HEADER, signed)
            }
        };
        let mut token = HeaderValue::from_str(&proof).map_err(|_| {
            refusal(
                "mcp_lys_credential_unreadable",
                "the agent's proof is not a header value".into(),
            )
        })?;
        token.set_sensitive(true);
        let mut response = self
            .client
            .post(self.route.as_str())
            .header(name, token)
            .header(header::CONTENT_TYPE, "application/json")
            .header(header::ACCEPT, "application/json")
            .send(REQUEST)
            .map_err(|error| self.could_not_ask(&error))?;
        let status = response.status().as_u16();
        let body = response
            .body_mut()
            .with_config()
            .limit(ANSWER_BYTES)
            .read_to_vec()
            .map_err(|error| match error {
                ureq::Error::BodyExceedsLimit(_) => refusal(
                    "mcp_lys_answer_invalid",
                    format!("Lys's pass answer exceeds {ANSWER_BYTES} bytes"),
                ),
                other => self.could_not_ask(&other),
            })?;
        if status != 200 {
            return Err(refused(status, &body));
        }
        let invalid = |why: &str| {
            refusal(
                "mcp_lys_answer_invalid",
                format!("Lys answered HTTP 200 without a usable pass: {why}"),
            )
        };
        let answer: Answer =
            serde_json::from_slice(&body).map_err(|_| invalid("the answer is not a pass"))?;
        if answer.audience != AUDIENCE {
            return Err(invalid("the pass names another audience"));
        }
        let pass = answer.pass.trim();
        if pass.is_empty() {
            return Err(invalid("the pass is empty"));
        }
        if answer.expires_at <= (self.clock)() {
            return Err(invalid("the pass has already expired"));
        }
        Ok(Held {
            pass: pass.to_owned(),
            expires_at: answer.expires_at,
        })
    }

    /// The grant credential, read now. Its text never enters a refusal.
    fn credential(&self) -> Result<String, ServiceRefusal> {
        let file = self.proof.file().ok_or_else(|| {
            refusal(
                "mcp_lys_credential_unreadable",
                "no credential file is configured".into(),
            )
        })?;
        let text = std::fs::read_to_string(file).map_err(|error| {
            refusal(
                "mcp_lys_credential_unreadable",
                format!("the credential file could not be read: {}", error.kind()),
            )
        })?;
        let trimmed = text.trim();
        if trimmed.is_empty() {
            return Err(refusal(
                "mcp_lys_credential_unreadable",
                "the credential file is empty".into(),
            ));
        }
        Ok(trimmed.to_owned())
    }

    fn could_not_ask(&self, error: &ureq::Error) -> ServiceRefusal {
        // ureq's words name the transport failure; the credential travels
        // only in a header, which no transport error repeats.
        refusal(
            "mcp_lys_unreachable",
            format!("Lys could not be asked at {}: {error}", self.route),
        )
    }
}

/// Lys's refusal as the tool's refusal: its name, when it gave one that is
/// plain text, and its HTTP status; never its body otherwise.
fn refused(status: u16, body: &[u8]) -> ServiceRefusal {
    let name = serde_json::from_slice::<Refused>(body)
        .ok()
        .map(|refused| refused.refusal)
        .filter(|name| {
            !name.is_empty()
                && name.len() <= REFUSAL_NAME_BYTES
                && name
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
        });
    let message = name.map_or_else(
        || format!("Lys refused the agent pass with HTTP {status} and no refusal name"),
        |name| format!("Lys refused the agent pass: {name} (HTTP {status})"),
    );
    refusal("mcp_lys_pass_refused", message)
}

fn refusal(code: &str, message: String) -> ServiceRefusal {
    ServiceRefusal {
        code: code.into(),
        message,
        receipt: None,
    }
}

/// `https://host[:port]`, or `http://` for 127.0.0.1 and localhost, with no
/// credentials, path (beyond `/`), query or fragment; `None` otherwise.
fn origin(value: &str) -> Option<String> {
    if value.contains('#') || !value.is_ascii() {
        return None;
    }
    let uri: Uri = value.parse().ok()?;
    let scheme = uri.scheme_str()?;
    let authority = uri.authority()?;
    let host = authority.host();
    let local = matches!(host, "127.0.0.1" | "localhost");
    let allowed = scheme == "https" || (scheme == "http" && local);
    let bare = uri.query().is_none() && matches!(uri.path(), "" | "/");
    (allowed && bare && !authority.as_str().contains('@'))
        .then(|| format!("{scheme}://{}", authority.as_str().to_ascii_lowercase()))
}

fn system_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |elapsed| elapsed.as_secs())
}

#[cfg(test)]
#[path = "lys_pass_tests.rs"]
mod tests;

impl LysAgent {
    /// The run pass in the environment variable `name`, read now. Its text
    /// never enters a refusal.
    fn run_pass(&self, name: &str) -> Result<String, ServiceRefusal> {
        let text = (self.environment)(name).ok_or_else(|| {
            refusal(
                "mcp_lys_credential_unreadable",
                format!("the run pass variable {name} is not set to text"),
            )
        })?;
        let trimmed = text.trim();
        if trimmed.is_empty() {
            return Err(refusal(
                "mcp_lys_credential_unreadable",
                format!("the run pass variable {name} is empty"),
            ));
        }
        Ok(trimmed.to_owned())
    }
}