Skip to main content

dent8_core/
content_check.rs

1//! Pluggable write-time content-check hook.
2//!
3//! dent8's `append` arbitration governs **authority, provenance, and lifecycle** — it
4//! deliberately never reads a fact's `value` text, so content-embedded attacks (injected
5//! imperatives, exfil instructions, obfuscated payloads, confident falsehoods) are admitted
6//! as inert data by design (see the honest non-blocks in `docs/evals.md`). This module is
7//! the seam that lets a deployment compose an **external content scanner** into the write
8//! boundary: dent8 ships **no classifier of its own** — the configured command (LLM Guard,
9//! Rebuff, a Lakera/Azure Prompt Shields bridge, a bespoke model, …) owns the content
10//! judgment, and dent8 owns running it on every candidate fact with no bypass.
11//!
12//! # Protocol (`dent8.content-check/1`)
13//!
14//! Per candidate fact (an event that carries a `value`), dent8 runs the configured command,
15//! writes one JSON payload ([`candidate_payload`]) to its stdin, and reads one JSON verdict
16//! object from its stdout:
17//!
18//! ```json
19//! {"verdict": "allow"}
20//! {"verdict": "reject", "reason": "why"}
21//! {"verdict": "taint",  "reason": "why"}
22//! ```
23//!
24//! `allow` admits the fact unchanged; `reject` refuses the write (nothing is persisted);
25//! `taint` admits the fact **but marks it** — a detect-only flag recorded as an
26//! [`Evidence`] item with a [`CONTENT_FLAG_LOCATOR_PREFIX`] locator, mirroring how
27//! retraction taint rides `DerivedFrom` evidence (ADR 0010): stored in the event, surfaced
28//! by `dent8 verify`, never silently dropped. Anything else — a non-zero exit, a timeout,
29//! malformed output — is a **scanner failure**, not a verdict, and is resolved by the
30//! configured [`FailurePolicy`].
31//!
32//! # Failure policy: fail-closed by default
33//!
34//! The default is [`FailurePolicy::FailClosed`]: a scanner you configured going dark must
35//! not silently readmit unchecked content — an attacker who can crash or wedge the scanner
36//! would otherwise hold a bypass. [`FailurePolicy::FailOpen`] (availability over scanning)
37//! admits the write but still **marks it** with a fail-open content flag, so even the
38//! permissive mode never admits unscanned content invisibly.
39//!
40//! No daemon and no network dependency live here: the hook is one short-lived subprocess
41//! per candidate fact, `std::process` only. Unconfigured (`enforce` never called, or called
42//! with no config by the CLI layer) is exact pass-through.
43
44use std::fmt;
45use std::io::{Read as _, Write as _};
46use std::process::{Child, Command, Stdio};
47use std::time::{Duration, Instant};
48
49use serde::Deserialize;
50
51use crate::ids::EvidenceId;
52use crate::model::{Evidence, EvidenceKind, FactEvent, FactValue};
53
54/// The stdin payload's protocol marker, so scanners can version-check what they parse.
55pub const CONTENT_CHECK_PROTOCOL: &str = "dent8.content-check/1";
56
57/// The `Evidence::locator` prefix that marks a content flag: `content-check:<scanner>`.
58/// The scanner's verdict is genuinely tool output, so the flag rides an ordinary
59/// [`EvidenceKind::ToolOutput`] item — no new event field, old logs keep byte-identical
60/// canonical form, and read surfaces detect the mark by this prefix.
61pub const CONTENT_FLAG_LOCATOR_PREFIX: &str = "content-check:";
62
63/// Cap on the scanner-supplied `reason` persisted into a content flag, so a hostile or
64/// buggy scanner cannot stuff megabytes into the event log through its verdict.
65const MAX_REASON_LEN: usize = 256;
66
67/// How often a still-running scanner is re-polled while waiting for the timeout.
68const POLL_INTERVAL: Duration = Duration::from_millis(10);
69
70/// What to do when the configured scanner itself fails (spawn error, timeout, non-zero
71/// exit, malformed verdict). Fail-closed is the default: see the module docs.
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum FailurePolicy {
74    /// Refuse the write. A configured-but-broken scanner blocks, never bypasses.
75    #[default]
76    FailClosed,
77    /// Admit the write, but mark it with a fail-open content flag so the unscanned admit
78    /// stays visible (`dent8 verify` surfaces it).
79    FailOpen,
80}
81
82/// The configured external scanner: a command line (program + args), a per-fact timeout,
83/// and the failure policy.
84#[derive(Clone, Debug)]
85pub struct ContentCheckConfig {
86    /// The scanner command: `command[0]` is the program, the rest are its arguments.
87    command: Vec<String>,
88    /// Per-candidate-fact wall-clock budget; past it the scanner is killed and the run
89    /// counts as a scanner failure.
90    pub timeout: Duration,
91    pub failure_policy: FailurePolicy,
92}
93
94impl ContentCheckConfig {
95    /// The default per-fact timeout (5 seconds).
96    pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
97
98    /// A config for `command` (program + args) with the default timeout and the default
99    /// fail-closed policy. Errors on an empty command.
100    pub fn new(command: Vec<String>) -> Result<Self, String> {
101        if command
102            .first()
103            .is_none_or(|program| program.trim().is_empty())
104        {
105            return Err("content check command cannot be empty".to_string());
106        }
107        Ok(Self {
108            command,
109            timeout: Self::DEFAULT_TIMEOUT,
110            failure_policy: FailurePolicy::default(),
111        })
112    }
113
114    /// The scanner's name as recorded in content flags: the configured program.
115    #[must_use]
116    pub fn scanner_name(&self) -> &str {
117        &self.command[0]
118    }
119}
120
121/// The scanner's judgment on one candidate fact.
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum Verdict {
124    /// Admit the fact unchanged.
125    Allow,
126    /// Refuse the write; nothing is persisted.
127    Reject { reason: Option<String> },
128    /// Admit the fact but mark it (detect-only, like retraction taint).
129    Taint { reason: Option<String> },
130}
131
132/// A scanner **failure** — not a verdict. Resolved by the configured [`FailurePolicy`].
133#[derive(Clone, Debug)]
134pub enum ScanError {
135    /// The command could not be spawned (missing binary, permissions).
136    Spawn(String),
137    /// An I/O failure while feeding or reading the scanner.
138    Io(String),
139    /// The scanner did not answer within the configured timeout and was killed.
140    Timeout(Duration),
141    /// The scanner exited non-zero.
142    NonZeroExit(String),
143    /// The scanner's stdout was not a well-formed verdict object.
144    MalformedVerdict(String),
145}
146
147impl fmt::Display for ScanError {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::Spawn(error) => write!(f, "could not spawn the scanner: {error}"),
151            Self::Io(error) => write!(f, "scanner I/O failed: {error}"),
152            Self::Timeout(timeout) => write!(
153                f,
154                "the scanner did not answer within {} ms and was killed",
155                timeout.as_millis()
156            ),
157            Self::NonZeroExit(detail) => write!(f, "the scanner exited non-zero ({detail})"),
158            Self::MalformedVerdict(detail) => {
159                write!(f, "the scanner returned a malformed verdict: {detail}")
160            }
161        }
162    }
163}
164
165impl std::error::Error for ScanError {}
166
167/// Why [`enforce`] refused a batch: a scanner `reject` verdict, or a scanner failure under
168/// the fail-closed policy.
169#[derive(Clone, Debug)]
170pub enum ContentCheckRefusal {
171    /// The scanner rejected a candidate fact's content.
172    Rejected {
173        subject: String,
174        predicate: String,
175        reason: Option<String>,
176    },
177    /// The scanner itself failed and the policy is fail-closed: refuse rather than
178    /// silently readmit unchecked content.
179    ScannerUnavailable(ScanError),
180}
181
182impl fmt::Display for ContentCheckRefusal {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::Rejected {
186                subject,
187                predicate,
188                reason,
189            } => {
190                write!(f, "content check rejected {subject} {predicate}")?;
191                if let Some(reason) = reason {
192                    write!(f, ": {reason}")?;
193                }
194                Ok(())
195            }
196            Self::ScannerUnavailable(error) => write!(
197                f,
198                "content check failed closed — {error} (the configured scanner must \
199                 answer, or set DENT8_CONTENT_CHECK_FAIL_OPEN=1 to admit-but-flag)"
200            ),
201        }
202    }
203}
204
205impl std::error::Error for ContentCheckRefusal {}
206
207/// A persisted content flag read back off an event: the scanner that flagged it and its
208/// stated reason.
209#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct ContentFlag {
211    pub scanner: String,
212    pub reason: String,
213}
214
215/// The first content flag on `event`, if any (an [`Evidence`] item whose locator carries
216/// [`CONTENT_FLAG_LOCATOR_PREFIX`]).
217#[must_use]
218pub fn content_flag(event: &FactEvent) -> Option<ContentFlag> {
219    event.evidence.iter().find_map(|item| {
220        let scanner = item.locator.strip_prefix(CONTENT_FLAG_LOCATOR_PREFIX)?;
221        Some(ContentFlag {
222            scanner: scanner.to_string(),
223            reason: item.summary.clone().unwrap_or_default(),
224        })
225    })
226}
227
228/// The `dent8.content-check/1` stdin payload for one candidate fact: everything a scanner
229/// needs to judge the content — the value, the attacker-influenceable evidence strings,
230/// and the arbitration-relevant metadata for context.
231#[must_use]
232pub fn candidate_payload(event: &FactEvent) -> serde_json::Value {
233    let value = match &event.value {
234        Some(FactValue::Text(text)) => serde_json::json!({ "kind": "text", "text": text }),
235        Some(FactValue::Json(json)) => serde_json::json!({ "kind": "json", "json": json.as_str() }),
236        Some(FactValue::Redacted) => serde_json::json!({ "kind": "redacted" }),
237        None => serde_json::Value::Null,
238    };
239    let evidence: Vec<serde_json::Value> = event
240        .evidence
241        .iter()
242        .map(|item| {
243            serde_json::json!({
244                "kind": format!("{:?}", item.kind),
245                "locator": item.locator,
246                "summary": item.summary,
247            })
248        })
249        .collect();
250    serde_json::json!({
251        "protocol": CONTENT_CHECK_PROTOCOL,
252        "event_id": event.event_id.as_str(),
253        "fact_id": event.fact_id.as_str(),
254        "event_type": event.kind.name(),
255        "subject": { "kind": event.subject.kind(), "key": event.subject.key() },
256        "predicate": event.predicate.as_str(),
257        "value": value,
258        "source": event.provenance.source.as_str(),
259        "authority": event.authority.level.name(),
260        "evidence": evidence,
261    })
262}
263
264/// The wire form of a scanner's stdout. Extra fields are tolerated (forward compatibility);
265/// an unknown `verdict` string is a malformed verdict, never a silent allow.
266#[derive(Deserialize)]
267struct WireVerdict {
268    verdict: String,
269    #[serde(default)]
270    reason: Option<String>,
271}
272
273/// Run the configured scanner on one candidate fact and parse its verdict.
274pub fn scan_event(config: &ContentCheckConfig, event: &FactEvent) -> Result<Verdict, ScanError> {
275    let payload = candidate_payload(event).to_string();
276    let stdout = run_scanner(config, &payload)?;
277    parse_verdict(&stdout)
278}
279
280fn parse_verdict(stdout: &str) -> Result<Verdict, ScanError> {
281    let trimmed = stdout.trim();
282    let wire: WireVerdict = serde_json::from_str(trimmed).map_err(|error| {
283        ScanError::MalformedVerdict(format!(
284            "{error} (stdout: {})",
285            truncate_chars(trimmed, 120)
286        ))
287    })?;
288    let reason = wire
289        .reason
290        .map(|reason| truncate_chars(&reason, MAX_REASON_LEN));
291    match wire.verdict.as_str() {
292        "allow" => Ok(Verdict::Allow),
293        "reject" => Ok(Verdict::Reject { reason }),
294        "taint" => Ok(Verdict::Taint { reason }),
295        other => Err(ScanError::MalformedVerdict(format!(
296            "unknown verdict {other:?} (allow|reject|taint)"
297        ))),
298    }
299}
300
301/// Truncate on a char boundary so a multi-byte reason cannot panic the slice.
302fn truncate_chars(text: &str, max: usize) -> String {
303    if text.chars().count() <= max {
304        return text.to_string();
305    }
306    let mut out: String = text.chars().take(max).collect();
307    out.push('…');
308    out
309}
310
311/// Spawn the scanner, feed it `payload`, and collect stdout — bounded by the configured
312/// timeout. Writer/reader run on threads so a scanner that neither reads its stdin nor
313/// bounds its stdout cannot deadlock the write path; `try_wait` polling (std has no waiting
314/// primitive with a deadline) keeps the whole run inside the budget, and a scanner that
315/// overruns it is killed.
316fn run_scanner(config: &ContentCheckConfig, payload: &str) -> Result<String, ScanError> {
317    let mut child = Command::new(&config.command[0])
318        .args(&config.command[1..])
319        .stdin(Stdio::piped())
320        .stdout(Stdio::piped())
321        .stderr(Stdio::null())
322        .spawn()
323        .map_err(|error| ScanError::Spawn(format!("{}: {error}", config.command[0])))?;
324
325    let mut stdin = child
326        .stdin
327        .take()
328        .ok_or_else(|| ScanError::Io("scanner stdin unavailable".to_string()))?;
329    let payload = payload.to_string();
330    // Errors writing stdin are ignored deliberately: a scanner may legitimately decide
331    // early and close its stdin (EPIPE); its verdict/exit status is what gets judged.
332    let writer = std::thread::spawn(move || {
333        let _ = stdin.write_all(payload.as_bytes());
334        drop(stdin);
335    });
336    let mut stdout_pipe = child
337        .stdout
338        .take()
339        .ok_or_else(|| ScanError::Io("scanner stdout unavailable".to_string()))?;
340    let (send_stdout, recv_stdout) = std::sync::mpsc::channel();
341    let reader = std::thread::spawn(move || {
342        let mut out = String::new();
343        let read = stdout_pipe.read_to_string(&mut out).map(|_| ());
344        let _ = send_stdout.send((read, out));
345    });
346
347    let status = match wait_with_timeout(&mut child, config.timeout) {
348        Ok(status) => status,
349        Err(error) => {
350            // Detach the pipe threads instead of joining: a killed scanner may leave
351            // orphaned grandchildren holding the pipes open (e.g. `sh` killed but a
352            // process it spawned still alive), and joining would hand them the write
353            // path's time.
354            drop(writer);
355            drop(reader);
356            return Err(error);
357        }
358    };
359    // The scanner exited; drain its stdout, still under a bounded wait — an orphaned
360    // grandchild inheriting the pipe must not be able to wedge the write path either.
361    // The writer is detached rather than joined for the same reason.
362    drop(writer);
363    let (read_result, stdout) = recv_stdout
364        .recv_timeout(config.timeout)
365        .map_err(|_| ScanError::Timeout(config.timeout))?;
366    let _ = reader.join();
367    read_result.map_err(|error| ScanError::Io(format!("reading scanner stdout: {error}")))?;
368    if !status.success() {
369        return Err(ScanError::NonZeroExit(format!(
370            "{status}; stdout: {}",
371            stdout.trim().chars().take(120).collect::<String>()
372        )));
373    }
374    Ok(stdout)
375}
376
377/// Poll `try_wait` until exit or deadline; on deadline, kill and reap, and report a
378/// timeout.
379fn wait_with_timeout(
380    child: &mut Child,
381    timeout: Duration,
382) -> Result<std::process::ExitStatus, ScanError> {
383    let deadline = Instant::now() + timeout;
384    loop {
385        match child.try_wait() {
386            Ok(Some(status)) => return Ok(status),
387            Ok(None) => {
388                if Instant::now() >= deadline {
389                    let _ = child.kill();
390                    let _ = child.wait();
391                    return Err(ScanError::Timeout(timeout));
392                }
393                std::thread::sleep(POLL_INTERVAL);
394            }
395            Err(error) => {
396                let _ = child.kill();
397                let _ = child.wait();
398                return Err(ScanError::Io(format!("waiting for the scanner: {error}")));
399            }
400        }
401    }
402}
403
404/// The content-flag evidence item recorded on a tainted (or fail-open) admit.
405fn flag_evidence(event: &FactEvent, scanner: &str, reason: &str) -> Evidence {
406    Evidence {
407        id: EvidenceId::new(format!(
408            "evidence:content-check:{}",
409            event.event_id.as_str()
410        ))
411        .expect("a content-flag evidence id built from a non-empty event id is non-empty"),
412        kind: EvidenceKind::ToolOutput,
413        locator: format!("{CONTENT_FLAG_LOCATOR_PREFIX}{scanner}"),
414        digest: None,
415        summary: Some(reason.to_string()),
416    }
417}
418
419/// Enforce the content check over a batch of candidate events, **before** they are
420/// arbitrated, attested, or persisted. Only events that carry a `value` are scanned —
421/// value-less lifecycle/audit events (supersession markers, retractions, retrieval
422/// records) introduce no new content. A `reject` verdict (or a scanner failure under
423/// fail-closed) refuses the whole batch so a multi-event operation stays all-or-nothing;
424/// a `taint` verdict marks the event in place ([`content_flag`]).
425pub fn enforce(
426    config: &ContentCheckConfig,
427    events: &mut [FactEvent],
428) -> Result<(), ContentCheckRefusal> {
429    for event in events.iter_mut() {
430        if event.value.is_none() {
431            continue;
432        }
433        match scan_event(config, event) {
434            Ok(Verdict::Allow) => {}
435            Ok(Verdict::Reject { reason }) => {
436                return Err(ContentCheckRefusal::Rejected {
437                    subject: format!("{}:{}", event.subject.kind(), event.subject.key()),
438                    predicate: event.predicate.as_str().to_string(),
439                    reason,
440                });
441            }
442            Ok(Verdict::Taint { reason }) => {
443                let reason = reason.unwrap_or_else(|| "flagged by the content check".to_string());
444                let flag = flag_evidence(event, config.scanner_name(), &reason);
445                event.evidence.push(flag);
446            }
447            Err(error) => match config.failure_policy {
448                FailurePolicy::FailClosed => {
449                    return Err(ContentCheckRefusal::ScannerUnavailable(error));
450                }
451                // Fail-open still marks the admit: unscanned content must stay visible.
452                FailurePolicy::FailOpen => {
453                    let reason = format!("content check unavailable (fail-open): {error}");
454                    let reason = truncate_chars(&reason, MAX_REASON_LEN);
455                    let flag = flag_evidence(event, config.scanner_name(), &reason);
456                    event.evidence.push(flag);
457                }
458            },
459        }
460    }
461    Ok(())
462}
463
464#[cfg(test)]
465mod tests {
466    use super::{
467        CONTENT_FLAG_LOCATOR_PREFIX, ContentCheckConfig, ContentCheckRefusal, FailurePolicy,
468        ScanError, Verdict, candidate_payload, content_flag, enforce, parse_verdict,
469    };
470    use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
471    use crate::model::{
472        Authority, AuthorityLevel, Confidence, Evidence, EvidenceKind, FactEvent, FactEventKind,
473        FactValue, Predicate, Provenance, Subject, Ttl,
474    };
475
476    fn event(value: Option<&str>) -> FactEvent {
477        FactEvent {
478            event_id: FactEventId::new("event:1").expect("event id"),
479            fact_id: FactId::new("fact:x").expect("fact id"),
480            kind: FactEventKind::Asserted,
481            subject: Subject::new("repo", "app").expect("subject"),
482            predicate: Predicate::new("build_command").expect("predicate"),
483            value: value.map(|text| FactValue::Text(text.to_string())),
484            confidence: Confidence::ASSERTED,
485            authority: Authority {
486                level: AuthorityLevel::Low,
487                issuer: None,
488                scope: None,
489            },
490            ttl: Ttl::Never,
491            provenance: Provenance {
492                source: SourceId::new("source:agent").expect("source"),
493                actor: ActorId::new("actor:test").expect("actor"),
494                tool: None,
495                run_id: None,
496                input_digest: None,
497                recorded_at: TimestampMillis::from_unix_millis(1),
498                attestation: None,
499            },
500            evidence: vec![Evidence {
501                id: EvidenceId::new("evidence:test").expect("evidence id"),
502                kind: EvidenceKind::UserStatement,
503                locator: "test".to_string(),
504                digest: None,
505                summary: None,
506            }],
507            observed_at: None,
508            valid_from: None,
509            valid_to: None,
510        }
511    }
512
513    #[test]
514    fn verdict_parsing_accepts_the_three_verdicts_and_rejects_the_rest() {
515        assert_eq!(
516            parse_verdict(r#"{"verdict":"allow"}"#).unwrap(),
517            Verdict::Allow
518        );
519        assert_eq!(
520            parse_verdict(r#"{"verdict":"reject","reason":"why"}"#).unwrap(),
521            Verdict::Reject {
522                reason: Some("why".to_string())
523            }
524        );
525        assert_eq!(
526            parse_verdict("  {\"verdict\":\"taint\"}\n").unwrap(),
527            Verdict::Taint { reason: None }
528        );
529        // Extra fields are tolerated (forward compatibility).
530        assert_eq!(
531            parse_verdict(r#"{"verdict":"allow","score":0.1}"#).unwrap(),
532            Verdict::Allow
533        );
534        // Unknown verdicts and non-JSON are malformed, never a silent allow.
535        assert!(matches!(
536            parse_verdict(r#"{"verdict":"maybe"}"#),
537            Err(ScanError::MalformedVerdict(_))
538        ));
539        assert!(matches!(
540            parse_verdict("yes"),
541            Err(ScanError::MalformedVerdict(_))
542        ));
543    }
544
545    #[test]
546    fn a_multibyte_malformed_verdict_is_reported_without_panicking() {
547        // 1 + 3*50 = 151 bytes but only 51 chars: byte 120 falls inside a '€',
548        // so a byte slice at 120 would panic before the failure policy applied.
549        let garbage = format!("a{}", "€".repeat(50));
550        let error = parse_verdict(&garbage).expect_err("non-JSON must be malformed");
551        assert!(matches!(error, ScanError::MalformedVerdict(_)));
552    }
553
554    #[test]
555    fn the_candidate_payload_carries_the_content_and_the_protocol_marker() {
556        let payload = candidate_payload(&event(Some("make test")));
557        assert_eq!(payload["protocol"], super::CONTENT_CHECK_PROTOCOL);
558        assert_eq!(payload["event_type"], "fact.asserted");
559        assert_eq!(payload["subject"]["kind"], "repo");
560        assert_eq!(payload["predicate"], "build_command");
561        assert_eq!(payload["value"]["kind"], "text");
562        assert_eq!(payload["value"]["text"], "make test");
563        assert_eq!(payload["authority"], "low");
564        assert_eq!(payload["evidence"][0]["locator"], "test");
565    }
566
567    #[test]
568    fn a_scanner_reason_is_bounded_before_it_is_persisted() {
569        let long = "x".repeat(10_000);
570        let Verdict::Taint {
571            reason: Some(reason),
572        } = parse_verdict(&format!(r#"{{"verdict":"taint","reason":"{long}"}}"#)).unwrap()
573        else {
574            panic!("expected a taint verdict with a reason");
575        };
576        assert!(reason.chars().count() <= super::MAX_REASON_LEN + 1);
577    }
578
579    #[test]
580    fn content_flags_round_trip_through_the_evidence_marker() {
581        let mut flagged = event(Some("suspicious"));
582        flagged
583            .evidence
584            .push(super::flag_evidence(&flagged, "demo.sh", "matched a rule"));
585        let flag = content_flag(&flagged).expect("flag present");
586        assert_eq!(flag.scanner, "demo.sh");
587        assert_eq!(flag.reason, "matched a rule");
588        assert!(
589            flagged.evidence[1]
590                .locator
591                .starts_with(CONTENT_FLAG_LOCATOR_PREFIX)
592        );
593        assert!(content_flag(&event(Some("clean"))).is_none());
594    }
595
596    #[test]
597    fn an_empty_command_is_refused_at_construction() {
598        assert!(ContentCheckConfig::new(vec![]).is_err());
599        assert!(ContentCheckConfig::new(vec![String::new()]).is_err());
600        assert!(ContentCheckConfig::new(vec!["scanner".to_string()]).is_ok());
601    }
602
603    #[test]
604    fn a_missing_scanner_fails_closed_by_default_and_flags_on_fail_open() {
605        let mut config =
606            ContentCheckConfig::new(vec!["/nonexistent/dent8-test-scanner".to_string()])
607                .expect("config");
608        let mut events = [event(Some("anything"))];
609        let refusal = enforce(&config, &mut events).expect_err("fail-closed must refuse");
610        assert!(matches!(
611            refusal,
612            ContentCheckRefusal::ScannerUnavailable(ScanError::Spawn(_))
613        ));
614
615        config.failure_policy = FailurePolicy::FailOpen;
616        enforce(&config, &mut events).expect("fail-open admits");
617        let flag = content_flag(&events[0]).expect("fail-open must mark the admit");
618        assert!(flag.reason.contains("fail-open"), "{}", flag.reason);
619    }
620
621    #[test]
622    fn value_less_events_are_not_scanned() {
623        // A config pointing at a nonexistent scanner would fail closed if it ran at all.
624        let config = ContentCheckConfig::new(vec!["/nonexistent/dent8-test-scanner".to_string()])
625            .expect("config");
626        let mut events = [event(None)];
627        enforce(&config, &mut events).expect("no value, no scan, no failure");
628    }
629
630    // Verdict-driven behaviour through a real subprocess: `/bin/sh` scanners on Unix.
631    #[cfg(unix)]
632    mod subprocess {
633        use std::io::Write as _;
634        use std::os::unix::fs::PermissionsExt as _;
635        use std::time::Duration;
636
637        use super::super::{
638            ContentCheckConfig, ContentCheckRefusal, FailurePolicy, ScanError, Verdict,
639            content_flag, enforce, scan_event,
640        };
641        use super::event;
642
643        /// Serialize the subprocess tests: a concurrently-forked sibling can otherwise
644        /// inherit another test's still-open script write fd between fork and exec,
645        /// making that exec fail with ETXTBSY ("Text file busy").
646        fn serialized() -> std::sync::MutexGuard<'static, ()> {
647            static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
648            LOCK.lock()
649                .unwrap_or_else(std::sync::PoisonError::into_inner)
650        }
651
652        /// Write an executable scanner script into a fresh temp dir and return its path.
653        fn scanner_script(body: &str) -> (std::path::PathBuf, tempdir::Guard) {
654            let dir = tempdir::create();
655            let path = dir.path.join("scanner.sh");
656            let mut file = std::fs::File::create(&path).expect("create script");
657            writeln!(file, "#!/bin/sh\n{body}").expect("write script");
658            drop(file);
659            let mut perms = std::fs::metadata(&path).expect("stat").permissions();
660            perms.set_mode(0o755);
661            std::fs::set_permissions(&path, perms).expect("chmod");
662            (path, dir)
663        }
664
665        /// A minimal self-cleaning temp dir (no external crate; MSRV-safe).
666        mod tempdir {
667            use std::path::PathBuf;
668            use std::sync::atomic::{AtomicU32, Ordering};
669
670            pub struct Guard {
671                pub path: PathBuf,
672            }
673
674            impl Drop for Guard {
675                fn drop(&mut self) {
676                    let _ = std::fs::remove_dir_all(&self.path);
677                }
678            }
679
680            pub fn create() -> Guard {
681                static COUNTER: AtomicU32 = AtomicU32::new(0);
682                let path = std::env::temp_dir().join(format!(
683                    "dent8-content-check-{}-{}",
684                    std::process::id(),
685                    COUNTER.fetch_add(1, Ordering::Relaxed)
686                ));
687                std::fs::create_dir_all(&path).expect("create temp dir");
688                Guard { path }
689            }
690        }
691
692        fn config_for(path: &std::path::Path) -> ContentCheckConfig {
693            ContentCheckConfig::new(vec![path.to_string_lossy().into_owned()]).expect("config")
694        }
695
696        #[test]
697        fn allow_reject_and_taint_verdicts_drive_enforcement() {
698            let _guard = serialized();
699            let (path, _dir) = scanner_script(
700                r#"input="$(cat)"
701case "$input" in
702  *poison*) printf '{"verdict":"reject","reason":"matched poison"}\n' ;;
703  *odd*)    printf '{"verdict":"taint","reason":"looks odd"}\n' ;;
704  *)        printf '{"verdict":"allow"}\n' ;;
705esac"#,
706            );
707            let config = config_for(&path);
708
709            assert_eq!(
710                scan_event(&config, &event(Some("a clean fact"))).unwrap(),
711                Verdict::Allow
712            );
713
714            let mut rejected = [event(Some("this is poison"))];
715            let refusal = enforce(&config, &mut rejected).expect_err("reject verdict refuses");
716            let ContentCheckRefusal::Rejected {
717                subject,
718                predicate,
719                reason,
720            } = refusal
721            else {
722                panic!("expected a rejection");
723            };
724            assert_eq!(subject, "repo:app");
725            assert_eq!(predicate, "build_command");
726            assert_eq!(reason.as_deref(), Some("matched poison"));
727
728            let mut tainted = [event(Some("something odd"))];
729            enforce(&config, &mut tainted).expect("taint admits");
730            let flag = content_flag(&tainted[0]).expect("taint must mark");
731            assert_eq!(flag.reason, "looks odd");
732
733            let mut clean = [event(Some("a clean fact"))];
734            enforce(&config, &mut clean).expect("allow admits");
735            assert!(content_flag(&clean[0]).is_none(), "allow must not mark");
736        }
737
738        #[test]
739        fn a_hung_scanner_is_killed_at_the_timeout_and_fails_closed() {
740            let _guard = serialized();
741            let (path, _dir) = scanner_script("cat > /dev/null\nsleep 60");
742            let mut config = config_for(&path);
743            config.timeout = Duration::from_millis(200);
744
745            let started = std::time::Instant::now();
746            let error = scan_event(&config, &event(Some("anything"))).expect_err("must time out");
747            assert!(matches!(error, ScanError::Timeout(_)), "{error}");
748            assert!(
749                started.elapsed() < Duration::from_secs(10),
750                "the kill must not wait for the scanner's sleep"
751            );
752
753            let mut events = [event(Some("anything"))];
754            let refusal = enforce(&config, &mut events).expect_err("fail-closed refuses");
755            assert!(matches!(
756                refusal,
757                ContentCheckRefusal::ScannerUnavailable(ScanError::Timeout(_))
758            ));
759        }
760
761        #[test]
762        fn a_non_zero_exit_and_garbage_stdout_are_scanner_failures() {
763            let _guard = serialized();
764            let (crash, _dir_a) = scanner_script("cat > /dev/null\nexit 3");
765            let error =
766                scan_event(&config_for(&crash), &event(Some("x"))).expect_err("non-zero exit");
767            assert!(matches!(error, ScanError::NonZeroExit(_)), "{error}");
768
769            let (garbage, _dir_b) = scanner_script("cat > /dev/null\necho not-json");
770            let error =
771                scan_event(&config_for(&garbage), &event(Some("x"))).expect_err("garbage stdout");
772            assert!(matches!(error, ScanError::MalformedVerdict(_)), "{error}");
773        }
774
775        #[test]
776        fn fail_open_admits_a_broken_scanner_run_but_marks_it() {
777            let _guard = serialized();
778            let (path, _dir) = scanner_script("cat > /dev/null\nexit 3");
779            let mut config = config_for(&path);
780            config.failure_policy = FailurePolicy::FailOpen;
781            let mut events = [event(Some("anything"))];
782            enforce(&config, &mut events).expect("fail-open admits");
783            let flag = content_flag(&events[0]).expect("fail-open must mark");
784            assert!(flag.reason.contains("fail-open"), "{}", flag.reason);
785        }
786    }
787}