crtx-llm 0.1.1

Claude, Ollama, and replay adapters behind a shared trait.
Documentation
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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! Deterministic LLM adapter that replays canned responses from on-disk
//! fixtures.
//!
//! `ReplayAdapter` is the default adapter for CI and any other context where
//! we refuse to spend tokens or leak prompts. It loads a directory of JSON
//! fixtures plus a sibling `INDEX.toml` and:
//!
//! 1. Verifies every fixture path is inside the fixtures directory.
//! 2. Verifies every fixture's on-disk BLAKE3 hash matches the value pinned
//!    in `INDEX.toml` (Lane 1.D / threat row T-RM-1: a fixture-keyed
//!    reflection is otherwise trivially forgeable by any actor with write
//!    access to the fixtures directory).
//! 3. On each `complete()` call, looks up `(model, prompt_hash)` and returns
//!    the pinned response.
//!
//! Fixture format is documented in `crates/cortex-llm/fixtures/schema.json`.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::adapter::{blake3_hex, LlmAdapter, LlmError, LlmRequest, LlmResponse, TokenUsage};

/// One row of the on-disk `INDEX.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
    /// Path to the fixture, relative to the fixtures directory and without
    /// any `..` components. The path is rooted inside the fixtures directory
    /// at load time.
    pub path: String,
    /// Lowercase hex BLAKE3 of the fixture's exact on-disk bytes.
    pub blake3: String,
}

/// On-disk representation of the signed fixture manifest.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FixtureIndex {
    /// Every fixture under the fixtures directory that the adapter trusts.
    #[serde(default, rename = "fixture")]
    pub fixtures: Vec<IndexEntry>,
}

/// Match criteria a fixture declares against incoming [`LlmRequest`]s.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureMatch {
    /// `LlmRequest::model` to match exactly.
    pub model: String,
    /// `LlmRequest::prompt_hash()` to match exactly.
    pub prompt_hash: String,
}

/// Response payload pinned by a fixture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureResponse {
    /// The reply text returned to the caller.
    pub text: String,
    /// Optional structured form when the original call asked for JSON.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parsed_json: Option<serde_json::Value>,
    /// Model the fixture claims emitted the reply (defaults to the request's
    /// model on absence — most fixtures echo the requested model).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Token-usage echo, optional.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub usage: Option<TokenUsage>,
}

/// Parsed shape of a single fixture file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureFile {
    /// Match criteria.
    pub request_match: FixtureMatch,
    /// Pinned response.
    pub response: FixtureResponse,
}

/// Deterministic adapter that returns fixture-pinned responses.
///
/// Thread-safe: holds an in-memory map keyed by `(model, prompt_hash)`.
#[derive(Debug)]
pub struct ReplayAdapter {
    fixtures_dir: PathBuf,
    by_key: HashMap<(String, String), FixtureFile>,
}

impl ReplayAdapter {
    /// Construct a new replay adapter rooted at `fixtures_dir`.
    ///
    /// The directory MUST contain an `INDEX.toml` listing every trusted
    /// fixture and its expected BLAKE3 hash. Any fixture in the directory
    /// that is **not** listed in the index, or whose on-disk bytes hash to a
    /// different value than the index records, causes
    /// [`LlmError::FixtureIntegrityFailed`].
    ///
    /// The check is upfront — once `new()` returns, the in-memory map is
    /// trusted for the lifetime of the adapter.
    pub fn new<P: Into<PathBuf>>(fixtures_dir: P) -> Result<Self, LlmError> {
        let fixtures_dir = fixtures_dir.into();
        let canonical_root = fs::canonicalize(&fixtures_dir)
            .map_err(|e| LlmError::Io(format!("fixtures dir {}: {e}", fixtures_dir.display())))?;

        let index_path = canonical_root.join("INDEX.toml");
        let index_text = fs::read_to_string(&index_path).map_err(|e| {
            LlmError::FixtureIntegrityFailed(format!(
                "INDEX.toml not readable at {}: {e}",
                index_path.display()
            ))
        })?;
        let index: FixtureIndex = toml::from_str(&index_text).map_err(|e| {
            LlmError::FixtureIntegrityFailed(format!("INDEX.toml parse error: {e}"))
        })?;

        // Build the trusted fixture set first (path → expected hash). This
        // also catches duplicate INDEX entries.
        let mut trusted: HashMap<PathBuf, String> = HashMap::new();
        for entry in &index.fixtures {
            let resolved = resolve_under(&canonical_root, &entry.path)?;
            if trusted
                .insert(resolved.clone(), entry.blake3.clone())
                .is_some()
            {
                return Err(LlmError::FixtureIntegrityFailed(format!(
                    "duplicate INDEX entry: {}",
                    entry.path
                )));
            }
        }

        // Reject any *.json fixture that is not in the trusted set
        // (defence against an attacker dropping a fresh fixture into the
        // directory after the index was committed). `INDEX.toml` itself is
        // skipped, as is `schema.json`.
        for dirent in fs::read_dir(&canonical_root)
            .map_err(|e| LlmError::Io(format!("scanning {}: {e}", canonical_root.display())))?
        {
            let dirent = dirent.map_err(|e| LlmError::Io(format!("dirent: {e}")))?;
            let path = dirent.path();
            if !path.is_file() {
                continue;
            }
            let name = path
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or_default();
            if name == "INDEX.toml" || name == "schema.json" {
                continue;
            }
            if !name.ends_with(".json") {
                continue;
            }
            if !trusted.contains_key(&path) {
                return Err(LlmError::FixtureIntegrityFailed(format!(
                    "unsigned fixture present (not in INDEX.toml): {}",
                    path.display()
                )));
            }
        }

        // Verify each trusted fixture's on-disk hash and load it.
        let mut by_key: HashMap<(String, String), FixtureFile> = HashMap::new();
        for (path, expected_hash) in trusted {
            let bytes = fs::read(&path).map_err(|e| {
                LlmError::FixtureIntegrityFailed(format!("read {}: {e}", path.display()))
            })?;
            let actual = blake3_hex(&bytes);
            if !constant_time_eq(actual.as_bytes(), expected_hash.as_bytes()) {
                return Err(LlmError::FixtureIntegrityFailed(format!(
                    "hash mismatch for {} (expected {expected_hash}, got {actual})",
                    path.display()
                )));
            }
            let fixture: FixtureFile = serde_json::from_slice(&bytes).map_err(|e| {
                LlmError::FixtureIntegrityFailed(format!("fixture {} parse: {e}", path.display()))
            })?;
            let key = (
                fixture.request_match.model.clone(),
                fixture.request_match.prompt_hash.clone(),
            );
            if by_key.insert(key, fixture).is_some() {
                return Err(LlmError::FixtureIntegrityFailed(format!(
                    "duplicate (model, prompt_hash) match in fixtures dir {}",
                    canonical_root.display()
                )));
            }
        }

        Ok(Self {
            fixtures_dir: canonical_root,
            by_key,
        })
    }

    /// Path the adapter is rooted at.
    #[must_use]
    pub fn fixtures_dir(&self) -> &Path {
        &self.fixtures_dir
    }

    /// Number of trusted fixtures currently loaded.
    #[must_use]
    pub fn fixture_count(&self) -> usize {
        self.by_key.len()
    }
}

#[async_trait]
impl LlmAdapter for ReplayAdapter {
    fn adapter_id(&self) -> &'static str {
        "replay"
    }

    async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
        let prompt_hash = req.prompt_hash();
        let key = (req.model.clone(), prompt_hash.clone());
        let Some(fixture) = self.by_key.get(&key) else {
            return Err(LlmError::NoFixture {
                model: req.model,
                prompt_hash,
            });
        };

        let text = fixture.response.text.clone();
        Ok(LlmResponse {
            text: text.clone(),
            parsed_json: fixture.response.parsed_json.clone(),
            model: fixture
                .response
                .model
                .clone()
                .unwrap_or_else(|| req.model.clone()),
            usage: fixture.response.usage.clone(),
            raw_hash: blake3_hex(text.as_bytes()),
        })
    }
}

/// Resolve `relative` under `root`, refusing any value that escapes the root
/// after canonicalization. Returns the absolute, canonical path on success.
fn resolve_under(root: &Path, relative: &str) -> Result<PathBuf, LlmError> {
    let candidate = root.join(relative);
    if candidate
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return Err(LlmError::FixtureIntegrityFailed(format!(
            "fixture path escapes fixtures dir: {relative}"
        )));
    }
    let canonical = fs::canonicalize(&candidate).map_err(|e| {
        LlmError::FixtureIntegrityFailed(format!(
            "fixture path {} not resolvable: {e}",
            candidate.display()
        ))
    })?;
    if !canonical.starts_with(root) {
        return Err(LlmError::FixtureIntegrityFailed(format!(
            "fixture path {} escapes fixtures dir {}",
            canonical.display(),
            root.display()
        )));
    }
    Ok(canonical)
}

/// Constant-time byte comparison used for the hash check. Pure stdlib; we do
/// not pull in `subtle` for one helper.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff: u8 = 0;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::adapter::{LlmMessage, LlmRole};
    use std::fs::File;
    use std::io::Write;
    use tempfile::TempDir;

    fn sample_request(content: &str) -> LlmRequest {
        LlmRequest {
            model: "claude-3-5-sonnet-20240620".into(),
            system: "you are a test".into(),
            messages: vec![LlmMessage {
                role: LlmRole::User,
                content: content.to_string(),
            }],
            temperature: 0.0,
            max_tokens: 256,
            json_schema: None,
            timeout_ms: 30_000,
        }
    }

    /// Write a single fixture under `dir/name`, produce its expected match
    /// criteria from `req`, and return both the resolved path and the
    /// fixture-file struct that was written.
    fn write_fixture(
        dir: &Path,
        name: &str,
        req: &LlmRequest,
        reply: &str,
    ) -> (PathBuf, FixtureFile) {
        let fixture = FixtureFile {
            request_match: FixtureMatch {
                model: req.model.clone(),
                prompt_hash: req.prompt_hash(),
            },
            response: FixtureResponse {
                text: reply.into(),
                parsed_json: None,
                model: None,
                usage: None,
            },
        };
        let path = dir.join(name);
        let bytes = serde_json::to_vec_pretty(&fixture).unwrap();
        let mut f = File::create(&path).unwrap();
        f.write_all(&bytes).unwrap();
        (path, fixture)
    }

    fn write_index(dir: &Path, entries: &[(&str, &str)]) {
        let mut s = String::new();
        for (path, hash) in entries {
            s.push_str(&format!(
                "[[fixture]]\npath = \"{path}\"\nblake3 = \"{hash}\"\n\n"
            ));
        }
        fs::write(dir.join("INDEX.toml"), s).unwrap();
    }

    fn hash_file(p: &Path) -> String {
        blake3_hex(&fs::read(p).unwrap())
    }

    #[tokio::test]
    async fn replay_returns_matching_fixture() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();

        let req_a = sample_request("hello");
        let req_b = sample_request("world");
        let (path_a, _) = write_fixture(dir, "a.json", &req_a, "hi from A");
        let (path_b, _) = write_fixture(dir, "b.json", &req_b, "hi from B");
        write_index(
            dir,
            &[
                ("a.json", &hash_file(&path_a)),
                ("b.json", &hash_file(&path_b)),
            ],
        );

        let adapter = ReplayAdapter::new(dir).unwrap();
        assert_eq!(adapter.fixture_count(), 2);
        let resp = adapter.complete(req_b).await.unwrap();
        assert_eq!(resp.text, "hi from B");
        assert_eq!(resp.model, "claude-3-5-sonnet-20240620");
    }

    #[tokio::test]
    async fn replay_rejects_unsigned_fixture() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();

        let req = sample_request("hello");
        let (path, _) = write_fixture(dir, "a.json", &req, "ok");
        // Drop a SECOND fixture that is NOT listed in the index.
        let (_path_b, _) = write_fixture(dir, "b-unsigned.json", &sample_request("world"), "boom");
        // INDEX only signs the first one.
        write_index(dir, &[("a.json", &hash_file(&path))]);

        let err = ReplayAdapter::new(dir).unwrap_err();
        match err {
            LlmError::FixtureIntegrityFailed(msg) => {
                assert!(
                    msg.contains("unsigned fixture present"),
                    "unexpected message: {msg}"
                );
            }
            other => panic!("expected FixtureIntegrityFailed, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn replay_rejects_hash_mismatch() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();

        let req = sample_request("hello");
        let (path, _) = write_fixture(dir, "a.json", &req, "ok");
        let original_hash = hash_file(&path);
        write_index(dir, &[("a.json", &original_hash)]);

        // Mutate one byte AFTER the index is written.
        let mut bytes = fs::read(&path).unwrap();
        // flip the last byte (which is `\n` or `}` either way: still valid bytes)
        let last = bytes.len() - 1;
        bytes[last] = bytes[last].wrapping_add(1);
        fs::write(&path, bytes).unwrap();

        let err = ReplayAdapter::new(dir).unwrap_err();
        match err {
            LlmError::FixtureIntegrityFailed(msg) => {
                assert!(msg.contains("hash mismatch"), "unexpected message: {msg}");
            }
            other => panic!("expected FixtureIntegrityFailed, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn replay_returns_no_fixture_when_unmatched() {
        let tmp = TempDir::new().unwrap();
        let dir = tmp.path();

        let req = sample_request("hello");
        let (path, _) = write_fixture(dir, "a.json", &req, "ok");
        write_index(dir, &[("a.json", &hash_file(&path))]);

        let adapter = ReplayAdapter::new(dir).unwrap();
        let other = sample_request("not in any fixture");
        let err = adapter.complete(other).await.unwrap_err();
        assert!(matches!(err, LlmError::NoFixture { .. }));
    }

    #[test]
    fn missing_index_is_integrity_failure() {
        let tmp = TempDir::new().unwrap();
        let err = ReplayAdapter::new(tmp.path()).unwrap_err();
        assert!(matches!(err, LlmError::FixtureIntegrityFailed(_)));
    }
}