Skip to main content

termivar_scanner/
template_evaluation_review.rs

1//! Raw-free contracts for one bounded benign template-evaluation review.
2//!
3//! The runtime owns request scheduling and evidence commitment. This module
4//! records only closed classifications and reconciled counts; expressions,
5//! query values, URLs, response bodies, template-engine errors, and other raw
6//! transport values are deliberately absent.
7
8use std::fmt;
9
10use thiserror::Error;
11
12/// Exact optional audit schema emitted by the central assessment renderer.
13pub const TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA: &str =
14    "security.template-evaluation-review-audit/v1";
15/// Stable policy revision for the bounded primary/replay case pair.
16pub const TEMPLATE_EVALUATION_REVIEW_POLICY_ID: &str = "termivar.template-evaluation-review/v1";
17/// Stable family identity for the harmless Jinja-compatible case vocabulary.
18pub const TEMPLATE_EVALUATION_REVIEW_FAMILY_ID: &str =
19    "web.review.template-evaluation.family.jinja-compatible-benign-expression@1";
20/// Stable knowledge-only capability projected by a complete positive review.
21pub const TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID: &str =
22    "web.review.template-evaluation.jinja-compatible-semantics@1";
23/// Exact title for the optional positive assessment item.
24pub(crate) const TEMPLATE_EVALUATION_REVIEW_TITLE: &str =
25    "Candidate-specific behavior consistent with Jinja-compatible expression semantics";
26/// Exact category for the optional positive assessment item.
27pub(crate) const TEMPLATE_EVALUATION_REVIEW_CATEGORY: &str = "Template expression evaluation";
28/// Exact value-free summary for the optional positive assessment item.
29pub(crate) const TEMPLATE_EVALUATION_REVIEW_SUMMARY: &str = "Two bounded candidate/replay cases produced candidate-specific benign expression semantics while controls remained negative; template engine identity, operating-system execution, file access, outbound interaction, and impact remain unestablished.";
30/// Stable remediation identity for the optional positive assessment item.
31pub(crate) const TEMPLATE_EVALUATION_REVIEW_REMEDIATION_ID: &str =
32    "web.remediation.template-evaluation-review@1";
33/// Exact remediation guidance for the optional positive assessment item.
34pub(crate) const TEMPLATE_EVALUATION_REVIEW_REMEDIATION_SUMMARY: &str = "Review server-side template construction and bind untrusted values as data rather than compiling them as template source.";
35
36/// Stable audit value for deliberately unestablished engine identity.
37pub const TEMPLATE_EVALUATION_ENGINE_IDENTITY: &str = "not_established";
38/// Stable audit value for deliberately excluded operations.
39pub const TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED: &str = "not_performed";
40/// Primary plus replay are the exact selected semantic cases.
41pub const TEMPLATE_EVALUATION_SELECTED_CASES: u8 = 2;
42/// Each selected case owns one control and one candidate request.
43pub const TEMPLATE_EVALUATION_MAX_REQUESTS: u8 = 4;
44/// Candidate requests are the only active legs.
45pub const TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS: u8 = 2;
46
47/// Fixed claim limits emitted in this exact order by every V1 audit.
48pub const TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS: [&str; 7] = [
49    "engine_identity_not_established",
50    "candidate_specific_evaluation_does_not_establish_template_engine_identity",
51    "outbound_interaction_not_performed",
52    "os_execution_not_performed",
53    "file_access_not_performed",
54    "impact_validation_not_performed",
55    "vulnerability_confirmation_not_established",
56];
57
58/// Closed raw-free outcome vocabulary retained by V1.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum TemplateEvaluationReviewOutcome {
61    /// No eligible committed parent observation selected the two semantic cases.
62    ParentNotObserved,
63    /// The candidate remained literal or was escaped rather than evaluated.
64    LiteralOrEscaped,
65    /// The response representation could not support the bounded classifier.
66    UnsupportedRepresentation,
67    /// Primary and replay did not establish the same candidate-specific result.
68    ReplayMismatch,
69    /// Both independent cases established their exact benign candidate result.
70    CandidateSpecificEvaluation,
71    /// Scheduling, transport, commitment, or classification ended incompletely.
72    Incomplete,
73}
74
75impl TemplateEvaluationReviewOutcome {
76    /// Stable value-free wire token.
77    pub const fn as_str(self) -> &'static str {
78        match self {
79            Self::ParentNotObserved => "parent_not_observed",
80            Self::LiteralOrEscaped => "literal_or_escaped",
81            Self::UnsupportedRepresentation => "unsupported_representation",
82            Self::ReplayMismatch => "replay_mismatch",
83            Self::CandidateSpecificEvaluation => "candidate_specific_evaluation",
84            Self::Incomplete => "incomplete",
85        }
86    }
87}
88
89/// Reconciled runtime facts accepted by the V1 audit constructor.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub(crate) struct TemplateEvaluationReviewAuditFacts {
92    pub(crate) outcome: TemplateEvaluationReviewOutcome,
93    pub(crate) selected_case_count: u8,
94    pub(crate) attempted_request_count: u8,
95    pub(crate) active_request_count: u8,
96    pub(crate) completed_response_count: u8,
97    pub(crate) committed_response_count: u8,
98    pub(crate) projected_item_count: u8,
99}
100
101/// Constructor failure for an internally inconsistent runtime summary.
102#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
103pub(crate) enum TemplateEvaluationReviewAuditError {
104    /// A counter exceeded the closed V1 plan.
105    #[error("template-evaluation review count exceeds its compiled limit")]
106    CountLimitExceeded,
107    /// Completed, committed, or active work exceeded its prerequisite work.
108    #[error("template-evaluation review counts are inconsistent")]
109    CountOrderingMismatch,
110    /// Counts and terminal outcome do not describe the same V1 state.
111    #[error("template-evaluation review outcome is inconsistent with its counts")]
112    OutcomeMismatch,
113}
114
115/// Value-free bounded audit retained by one composed assessment report.
116#[derive(Clone, Eq, PartialEq)]
117pub struct TemplateEvaluationReviewAudit {
118    outcome: TemplateEvaluationReviewOutcome,
119    selected_case_count: u8,
120    attempted_request_count: u8,
121    active_request_count: u8,
122    completed_response_count: u8,
123    committed_response_count: u8,
124    projected_item_count: u8,
125}
126
127impl TemplateEvaluationReviewAudit {
128    /// Constructs an audit only from counts that reconcile with the closed V1
129    /// primary/replay schedule and terminal outcome.
130    pub(crate) fn from_runtime(
131        facts: TemplateEvaluationReviewAuditFacts,
132    ) -> Result<Self, TemplateEvaluationReviewAuditError> {
133        validate_facts(facts)?;
134        Ok(Self {
135            outcome: facts.outcome,
136            selected_case_count: facts.selected_case_count,
137            attempted_request_count: facts.attempted_request_count,
138            active_request_count: facts.active_request_count,
139            completed_response_count: facts.completed_response_count,
140            committed_response_count: facts.committed_response_count,
141            projected_item_count: facts.projected_item_count,
142        })
143    }
144
145    /// Exact optional audit schema.
146    pub const fn schema(&self) -> &'static str {
147        TEMPLATE_EVALUATION_REVIEW_AUDIT_SCHEMA
148    }
149
150    /// Stable policy revision.
151    pub const fn policy_id(&self) -> &'static str {
152        TEMPLATE_EVALUATION_REVIEW_POLICY_ID
153    }
154
155    /// Stable harmless expression-family identity.
156    pub const fn family_id(&self) -> &'static str {
157        TEMPLATE_EVALUATION_REVIEW_FAMILY_ID
158    }
159
160    /// Stable capability identity for the optional projected item.
161    pub const fn capability_id(&self) -> &'static str {
162        TEMPLATE_EVALUATION_REVIEW_CAPABILITY_ID
163    }
164
165    /// Raw-free terminal outcome.
166    pub const fn outcome(&self) -> TemplateEvaluationReviewOutcome {
167        self.outcome
168    }
169
170    /// Number of selected semantic cases.
171    pub const fn selected_case_count(&self) -> u8 {
172        self.selected_case_count
173    }
174
175    /// Number of broker requests attempted by the review.
176    pub const fn attempted_request_count(&self) -> u8 {
177        self.attempted_request_count
178    }
179
180    /// Number of attempted requests charged as active review work.
181    pub const fn active_request_count(&self) -> u8 {
182        self.active_request_count
183    }
184
185    /// Number of requests that produced complete responses.
186    pub const fn completed_response_count(&self) -> u8 {
187        self.completed_response_count
188    }
189
190    /// Number of complete responses committed as usable evidence.
191    pub const fn committed_response_count(&self) -> u8 {
192        self.committed_response_count
193    }
194
195    /// Number of positive assessment items projected from this audit.
196    pub const fn projected_item_count(&self) -> u8 {
197        self.projected_item_count
198    }
199
200    /// Engine identity is deliberately not established by benign semantics.
201    pub const fn engine_identity(&self) -> &'static str {
202        TEMPLATE_EVALUATION_ENGINE_IDENTITY
203    }
204
205    /// Outbound interaction was deliberately not performed.
206    pub const fn outbound_interaction(&self) -> &'static str {
207        TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
208    }
209
210    /// Operating-system execution was deliberately not performed.
211    pub const fn os_execution(&self) -> &'static str {
212        TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
213    }
214
215    /// File access was deliberately not performed.
216    pub const fn file_access(&self) -> &'static str {
217        TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
218    }
219
220    /// Impact validation was deliberately not performed.
221    pub const fn impact_validation(&self) -> &'static str {
222        TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED
223    }
224
225    /// Fixed claim limits for the V1 review.
226    pub const fn claim_limits(&self) -> &'static [&'static str; 7] {
227        &TEMPLATE_EVALUATION_REVIEW_CLAIM_LIMITS
228    }
229}
230
231impl fmt::Debug for TemplateEvaluationReviewAudit {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        formatter
234            .debug_struct("TemplateEvaluationReviewAudit")
235            .field("schema", &self.schema())
236            .field("policy_id", &self.policy_id())
237            .field("family_id", &self.family_id())
238            .field("outcome", &self.outcome)
239            .field("selected_case_count", &self.selected_case_count)
240            .field("attempted_request_count", &self.attempted_request_count)
241            .field("active_request_count", &self.active_request_count)
242            .field("completed_response_count", &self.completed_response_count)
243            .field("committed_response_count", &self.committed_response_count)
244            .field("projected_item_count", &self.projected_item_count)
245            .field("engine_identity", &self.engine_identity())
246            .field("operations", &TEMPLATE_EVALUATION_OPERATION_NOT_PERFORMED)
247            .finish()
248    }
249}
250
251fn validate_facts(
252    facts: TemplateEvaluationReviewAuditFacts,
253) -> Result<(), TemplateEvaluationReviewAuditError> {
254    if !matches!(
255        facts.selected_case_count,
256        0 | TEMPLATE_EVALUATION_SELECTED_CASES
257    ) || facts.attempted_request_count > TEMPLATE_EVALUATION_MAX_REQUESTS
258        || facts.active_request_count > TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS
259        || facts.completed_response_count > TEMPLATE_EVALUATION_MAX_REQUESTS
260        || facts.committed_response_count > TEMPLATE_EVALUATION_MAX_REQUESTS
261        || facts.projected_item_count > 1
262    {
263        return Err(TemplateEvaluationReviewAuditError::CountLimitExceeded);
264    }
265    if facts.completed_response_count > facts.attempted_request_count
266        || facts.committed_response_count > facts.completed_response_count
267        || facts.active_request_count > facts.attempted_request_count
268    {
269        return Err(TemplateEvaluationReviewAuditError::CountOrderingMismatch);
270    }
271
272    let valid_outcome = match facts.outcome {
273        TemplateEvaluationReviewOutcome::ParentNotObserved => {
274            facts.selected_case_count == 0
275                && facts.attempted_request_count == 0
276                && facts.active_request_count == 0
277                && facts.completed_response_count == 0
278                && facts.committed_response_count == 0
279                && facts.projected_item_count == 0
280        },
281        TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation => {
282            facts.selected_case_count == TEMPLATE_EVALUATION_SELECTED_CASES
283                && facts.attempted_request_count == TEMPLATE_EVALUATION_MAX_REQUESTS
284                && facts.active_request_count == TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS
285                && facts.completed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
286                && facts.committed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
287                && facts.projected_item_count == 1
288        },
289        TemplateEvaluationReviewOutcome::LiteralOrEscaped
290        | TemplateEvaluationReviewOutcome::UnsupportedRepresentation
291        | TemplateEvaluationReviewOutcome::ReplayMismatch => {
292            facts.selected_case_count == TEMPLATE_EVALUATION_SELECTED_CASES
293                && facts.attempted_request_count == TEMPLATE_EVALUATION_MAX_REQUESTS
294                && facts.active_request_count == TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS
295                && facts.completed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
296                && facts.committed_response_count == TEMPLATE_EVALUATION_MAX_REQUESTS
297                && facts.projected_item_count == 0
298        },
299        TemplateEvaluationReviewOutcome::Incomplete => {
300            facts.selected_case_count == TEMPLATE_EVALUATION_SELECTED_CASES
301                && facts.projected_item_count == 0
302        },
303    };
304    if !valid_outcome {
305        return Err(TemplateEvaluationReviewAuditError::OutcomeMismatch);
306    }
307    Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn facts(outcome: TemplateEvaluationReviewOutcome) -> TemplateEvaluationReviewAuditFacts {
315        TemplateEvaluationReviewAuditFacts {
316            outcome,
317            selected_case_count: TEMPLATE_EVALUATION_SELECTED_CASES,
318            attempted_request_count: TEMPLATE_EVALUATION_MAX_REQUESTS,
319            active_request_count: TEMPLATE_EVALUATION_MAX_ACTIVE_REQUESTS,
320            completed_response_count: TEMPLATE_EVALUATION_MAX_REQUESTS,
321            committed_response_count: TEMPLATE_EVALUATION_MAX_REQUESTS,
322            projected_item_count: 0,
323        }
324    }
325
326    #[test]
327    fn selected_empty_and_positive_states_are_exact() {
328        let empty =
329            TemplateEvaluationReviewAudit::from_runtime(TemplateEvaluationReviewAuditFacts {
330                outcome: TemplateEvaluationReviewOutcome::ParentNotObserved,
331                selected_case_count: 0,
332                attempted_request_count: 0,
333                active_request_count: 0,
334                completed_response_count: 0,
335                committed_response_count: 0,
336                projected_item_count: 0,
337            })
338            .unwrap();
339        assert_eq!(empty.outcome().as_str(), "parent_not_observed");
340        assert_eq!(empty.selected_case_count(), 0);
341
342        let mut positive = facts(TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation);
343        positive.projected_item_count = 1;
344        let positive = TemplateEvaluationReviewAudit::from_runtime(positive).unwrap();
345        assert_eq!(positive.projected_item_count(), 1);
346        assert_eq!(positive.engine_identity(), "not_established");
347        assert_eq!(positive.claim_limits().len(), 7);
348    }
349
350    #[test]
351    fn all_nonpositive_outcomes_remain_item_free() {
352        for outcome in [
353            TemplateEvaluationReviewOutcome::LiteralOrEscaped,
354            TemplateEvaluationReviewOutcome::UnsupportedRepresentation,
355            TemplateEvaluationReviewOutcome::ReplayMismatch,
356            TemplateEvaluationReviewOutcome::Incomplete,
357        ] {
358            let audit = TemplateEvaluationReviewAudit::from_runtime(facts(outcome)).unwrap();
359            assert_eq!(audit.projected_item_count(), 0);
360            assert_eq!(audit.outbound_interaction(), "not_performed");
361            assert_eq!(audit.os_execution(), "not_performed");
362            assert_eq!(audit.file_access(), "not_performed");
363            assert_eq!(audit.impact_validation(), "not_performed");
364        }
365
366        let partial =
367            TemplateEvaluationReviewAudit::from_runtime(TemplateEvaluationReviewAuditFacts {
368                outcome: TemplateEvaluationReviewOutcome::Incomplete,
369                selected_case_count: TEMPLATE_EVALUATION_SELECTED_CASES,
370                attempted_request_count: 3,
371                active_request_count: 1,
372                completed_response_count: 2,
373                committed_response_count: 2,
374                projected_item_count: 0,
375            })
376            .unwrap();
377        assert_eq!(
378            partial.outcome(),
379            TemplateEvaluationReviewOutcome::Incomplete
380        );
381    }
382
383    #[test]
384    fn invalid_limits_ordering_and_outcome_shapes_fail_closed() {
385        let mut too_many = facts(TemplateEvaluationReviewOutcome::Incomplete);
386        too_many.attempted_request_count = 5;
387        assert_eq!(
388            TemplateEvaluationReviewAudit::from_runtime(too_many),
389            Err(TemplateEvaluationReviewAuditError::CountLimitExceeded)
390        );
391
392        let mut out_of_order = facts(TemplateEvaluationReviewOutcome::Incomplete);
393        out_of_order.completed_response_count = 3;
394        out_of_order.committed_response_count = 4;
395        assert_eq!(
396            TemplateEvaluationReviewAudit::from_runtime(out_of_order),
397            Err(TemplateEvaluationReviewAuditError::CountOrderingMismatch)
398        );
399
400        let mut false_positive =
401            facts(TemplateEvaluationReviewOutcome::CandidateSpecificEvaluation);
402        false_positive.projected_item_count = 0;
403        assert_eq!(
404            TemplateEvaluationReviewAudit::from_runtime(false_positive),
405            Err(TemplateEvaluationReviewAuditError::OutcomeMismatch)
406        );
407
408        let mut projected_negative = facts(TemplateEvaluationReviewOutcome::ReplayMismatch);
409        projected_negative.projected_item_count = 1;
410        assert_eq!(
411            TemplateEvaluationReviewAudit::from_runtime(projected_negative),
412            Err(TemplateEvaluationReviewAuditError::OutcomeMismatch)
413        );
414
415        for outcome in [
416            TemplateEvaluationReviewOutcome::LiteralOrEscaped,
417            TemplateEvaluationReviewOutcome::UnsupportedRepresentation,
418            TemplateEvaluationReviewOutcome::ReplayMismatch,
419        ] {
420            let mut incomplete_terminal = facts(outcome);
421            incomplete_terminal.attempted_request_count = 0;
422            incomplete_terminal.active_request_count = 0;
423            incomplete_terminal.completed_response_count = 0;
424            incomplete_terminal.committed_response_count = 0;
425            assert_eq!(
426                TemplateEvaluationReviewAudit::from_runtime(incomplete_terminal),
427                Err(TemplateEvaluationReviewAuditError::OutcomeMismatch)
428            );
429        }
430    }
431}