Skip to main content

touchstone_harness/
lib.rs

1//! touchstone-harness: drives adapters, collects check results, scores runs.
2//!
3//! Two adapter shapes are supported:
4//! - [`Adapter`] — an in-process Rust trait for native implementations
5//! - [`ExecAdapter`] — any executable printing newline-delimited JSON check
6//!   results on stdout (the language-agnostic spec protocol, SPEC.md §5)
7
8#![forbid(unsafe_code)]
9
10use std::io::{BufRead, BufReader};
11use std::process::{Command, Stdio};
12use std::time::Duration;
13
14use touchstone_core::{
15    verdict_for, Attestation, CheckResult, Organ, Status, Subject, SPEC_VERSION,
16};
17
18/// Errors from driving an adapter.
19#[derive(Debug, thiserror::Error)]
20pub enum HarnessError {
21    /// The adapter binary could not be spawned at all.
22    #[error("adapter failed to spawn: {0}")]
23    Spawn(std::io::Error),
24    /// The adapter exited nonzero — harness error, not a check failure.
25    #[error("adapter exited with status {0}")]
26    ExitStatus(std::process::ExitStatus),
27    /// The adapter outlived its wall-clock budget and was killed.
28    #[error("adapter exceeded timeout of {0:?}")]
29    Timeout(std::time::Duration),
30    /// The adapter emitted zero parseable check results.
31    #[error("adapter produced no check results")]
32    NoResults,
33    /// A protocol line was structurally invalid.
34    #[error("invalid protocol line: {0}")]
35    Protocol(String),
36}
37
38/// In-process adapter trait. Implement this to conform a subject natively.
39pub trait Adapter {
40    /// Human-readable adapter name.
41    fn name(&self) -> &str;
42    /// Run all checks and return their results.
43    fn run(&self) -> Vec<CheckResult>;
44}
45
46/// One line of the exec protocol emitted by an external adapter binary.
47#[derive(serde::Deserialize)]
48struct ProtocolLine {
49    check: String,
50    organ: String,
51    status: String,
52    #[serde(default)]
53    evidence: serde_json::Value,
54    #[serde(default)]
55    control: bool,
56}
57
58fn parse_organ(s: &str) -> Option<Organ> {
59    Some(match s {
60        "awake" => Organ::Awake,
61        "identity" => Organ::Identity,
62        "perception" => Organ::Perception,
63        "memory" => Organ::Memory,
64        "deliberation" => Organ::Deliberation,
65        "action" => Organ::Action,
66        "vigilance" => Organ::Vigilance,
67        "learning" => Organ::Learning,
68        "audit" => Organ::Audit,
69        "sovereignty" => Organ::Sovereignty,
70        _ => return None,
71    })
72}
73
74fn parse_status(s: &str, control: bool) -> Option<Status> {
75    Some(match s {
76        "pass" => Status::Pass,
77        "fail" => {
78            if control {
79                Status::ControlOk
80            } else {
81                Status::Fail
82            }
83        }
84        "control_ok" => Status::ControlOk,
85        "optional" | "skip" => Status::Optional,
86        _ => return None,
87    })
88}
89
90/// Parse one protocol line into a `CheckResult` (None if unparseable).
91fn parse_line(line: &str) -> Option<CheckResult> {
92    let trimmed = line.trim();
93    if trimmed.is_empty() || !trimmed.starts_with('{') {
94        return None;
95    }
96    let p: ProtocolLine = serde_json::from_str(trimmed).ok()?;
97    let organ = parse_organ(&p.organ)?;
98    let status = parse_status(&p.status, p.control)?;
99    Some(CheckResult {
100        id: p.check,
101        organ,
102        status,
103        evidence: p.evidence,
104        control: p.control,
105    })
106}
107
108/// An external adapter binary speaking the newline-JSON protocol.
109pub struct ExecAdapter {
110    /// Path or name of the adapter executable.
111    pub program: String,
112    /// Arguments passed through to the adapter.
113    pub args: Vec<String>,
114    /// Wall-clock budget for the whole run; exceeded adapters are killed.
115    pub timeout: Duration,
116}
117
118impl ExecAdapter {
119    /// Create an adapter driver for `program` with the spec's default
120    /// timeout (600 s).
121    pub fn new(program: impl Into<String>) -> Self {
122        Self {
123            program: program.into(),
124            args: Vec::new(),
125            timeout: Duration::from_secs(600),
126        }
127    }
128
129    /// Spawn the adapter, read NDJSON results, convert protocol lines into
130    /// check results (control failures normalize to `ControlOk`).
131    ///
132    /// Enforces `self.timeout`: an adapter that outlives it is killed and
133    /// reported as `HarnessError::Timeout` rather than hanging the caller.
134    pub fn collect(&self) -> Result<Vec<CheckResult>, HarnessError> {
135        let mut child = Command::new(&self.program)
136            .args(&self.args)
137            .stdout(Stdio::piped())
138            .stderr(Stdio::inherit())
139            .spawn()
140            .map_err(HarnessError::Spawn)?;
141
142        let stdout = child.stdout.take().expect("stdout piped");
143        // Read on a worker thread so the main thread can enforce the deadline.
144        let (tx, rx) = std::sync::mpsc::channel();
145        let reader = std::thread::spawn(move || {
146            for line in BufReader::new(stdout).lines() {
147                match line {
148                    Ok(l) => {
149                        if tx.send(l).is_err() {
150                            return;
151                        }
152                    }
153                    Err(_) => return,
154                }
155            }
156        });
157
158        let deadline = std::time::Instant::now() + self.timeout;
159        let mut results = Vec::new();
160        loop {
161            match rx.recv_timeout(Duration::from_millis(100)) {
162                Ok(line) => {
163                    if let Some(r) = parse_line(&line) {
164                        results.push(r);
165                    }
166                }
167                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
168                    if std::time::Instant::now() > deadline {
169                        let _ = child.kill();
170                        let _ = child.wait();
171                        return Err(HarnessError::Timeout(self.timeout));
172                    }
173                    match child.try_wait() {
174                        Ok(Some(_)) => {
175                            // exited; drain whatever is left
176                            for line in rx.try_iter() {
177                                if let Some(r) = parse_line(&line) {
178                                    results.push(r);
179                                }
180                            }
181                            break;
182                        }
183                        Ok(None) => {}
184                        Err(_) => break,
185                    }
186                }
187                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
188            }
189        }
190        let _ = reader.join();
191
192        let status = child.wait().map_err(HarnessError::Spawn)?;
193        if !status.success() {
194            return Err(HarnessError::ExitStatus(status));
195        }
196        if results.is_empty() {
197            return Err(HarnessError::NoResults);
198        }
199        Ok(results)
200    }
201}
202
203/// Score a collected run into an attestation (unsigned).
204pub fn attest(
205    subject: Subject,
206    checks: Vec<CheckResult>,
207    timestamp: impl Into<String>,
208) -> Attestation {
209    let verdict = verdict_for(&checks);
210    Attestation {
211        spec: format!("touchstone/{SPEC_VERSION}"),
212        subject,
213        timestamp: timestamp.into(),
214        checks,
215        verdict,
216        signature: None,
217    }
218}
219
220/// The checklist itself — the spec rendered as data, for `touchstone spec`.
221pub const SPEC_CHECKS: &[(&str, Organ, &str)] = &[
222    (
223        "awake.process",
224        Organ::Awake,
225        "persistent process reports liveness",
226    ),
227    (
228        "identity.declare",
229        Organ::Identity,
230        "declares what it is, bound to a device key",
231    ),
232    (
233        "perception.primary",
234        Organ::Perception,
235        "first opt-in sense reads a planted stimulus",
236    ),
237    (
238        "perception.secondary",
239        Organ::Perception,
240        "second opt-in sense reads a planted stimulus",
241    ),
242    (
243        "memory.store_recall",
244        Organ::Memory,
245        "stores a token and recalls it later",
246    ),
247    (
248        "memory.continuity",
249        Organ::Memory,
250        "history persists across days/restarts",
251    ),
252    (
253        "deliberation.record",
254        Organ::Deliberation,
255        "structured decision procedure produces a recorded verdict",
256    ),
257    (
258        "action.tool_ledgered",
259        Organ::Action,
260        "a tool execution lands on the audit record",
261    ),
262    (
263        "vigilance.watcher",
264        Organ::Vigilance,
265        "a watcher or standing order fires unprompted",
266    ),
267    (
268        "learning.self_improve",
269        Organ::Learning,
270        "weight/strategy-level self-improvement evidence",
271    ),
272    (
273        "audit.chain_valid",
274        Organ::Audit,
275        "hash-chained log verifies independently",
276    ),
277    (
278        "audit.control_negative",
279        Organ::Audit,
280        "planted failure reports FAIL (control check)",
281    ),
282    (
283        "sovereignty.no_egress",
284        Organ::Sovereignty,
285        "no required external network sockets",
286    ),
287    (
288        "sovereignty.kill_path",
289        Organ::Sovereignty,
290        "owner-held stop path exists",
291    ),
292];
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use touchstone_core::{CheckResult as CR, Verdict};
298
299    struct Toy;
300    impl Adapter for Toy {
301        fn name(&self) -> &'static str {
302            "toy"
303        }
304        fn run(&self) -> Vec<CR> {
305            Organ::ALL
306                .iter()
307                .map(|o| CR {
308                    id: "t".into(),
309                    organ: *o,
310                    status: Status::Pass,
311                    evidence: serde_json::Value::Null,
312                    control: false,
313                })
314                .collect()
315        }
316    }
317
318    #[test]
319    fn toy_adapter_conforms() {
320        let checks = Toy.run();
321        let doc = attest(
322            Subject {
323                name: "toy".into(),
324                version: "0".into(),
325                host: "h".into(),
326            },
327            checks,
328            "now",
329        );
330        assert_eq!(doc.verdict, Verdict::Conformant);
331        assert!(doc.spec.starts_with("touchstone/"));
332    }
333
334    #[test]
335    fn protocol_parsing() {
336        assert_eq!(parse_organ("perception"), Some(Organ::Perception));
337        assert_eq!(parse_organ("nope"), None);
338        assert_eq!(parse_status("fail", true), Some(Status::ControlOk));
339        assert_eq!(parse_status("fail", false), Some(Status::Fail));
340        assert_eq!(parse_status("optional", false), Some(Status::Optional));
341    }
342}