Skip to main content

iac_forge/
remediation.rs

1//! Remediation harness — bounded, auditable transform application.
2//!
3//! A remediation takes a proposal (a transform script, typically from
4//! a human reviewer or an LLM), applies it to a source IR, and returns
5//! a full [`Outcome`]: the post-state IR, the edit list that describes
6//! what changed, the before/after content hashes, and any invariant
7//! violations the transform introduced.
8//!
9//! The *whole point* of the harness is that the LLM-or-human author
10//! of the proposal doesn't have to be trusted — the script is bounded
11//! by the existing [`ResourceOp`] vocabulary, parse failures are
12//! rejected before anything runs, and the Outcome carries a complete
13//! audit trail so tameshi / sekiban / kensa can gate on it.
14//!
15//! # Flow
16//!
17//! ```
18//! # use iac_forge::remediation::{Proposal, apply_proposal};
19//! # use iac_forge::testing::test_resource;
20//! let resource = test_resource("widget");
21//! // `test_resource` carries the attributes `name`, `value`, `tags`.
22//! // `tags` is not yet sensitive, so this proposal genuinely changes it.
23//! let proposal = Proposal::new(
24//!     "flag tags sensitive",
25//!     r#"(mark-sensitive "tags")"#,
26//! );
27//! let outcome = apply_proposal(&resource, &proposal).expect("apply");
28//! assert_eq!(outcome.proposal_reason, "flag tags sensitive");
29//! assert!(outcome.changed());
30//! assert_ne!(outcome.before_hash, outcome.after_hash);
31//!
32//! // A proposal naming an attribute the resource does not have is a
33//! // well-formed no-op: it parses, applies, and reports "nothing changed".
34//! let absent = Proposal::new("no such field", r#"(mark-sensitive "database_url")"#);
35//! let outcome = apply_proposal(&resource, &absent).expect("apply");
36//! assert!(!outcome.changed());
37//! assert_eq!(outcome.before_hash, outcome.after_hash);
38//! ```
39
40use crate::ir::IacResource;
41use crate::sexpr::{SExpr, SExprError, ToSExpr};
42use crate::sexpr_diff::{Edit, diff};
43use crate::transform::ops::ResourceOp;
44use crate::transform::{Transform, script};
45
46/// A remediation proposal — script text plus the human-readable reason
47/// a caller wants to apply it.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Proposal {
50    /// Free-form description of why this remediation exists (e.g.,
51    /// "PCI-DSS §3.4 — flag cardholder data fields sensitive").
52    pub reason: String,
53    /// s-expression script in the [`crate::transform::script`] surface.
54    pub script: String,
55}
56
57impl Proposal {
58    /// Construct a proposal.
59    pub fn new(reason: impl Into<String>, script: impl Into<String>) -> Self {
60        Self {
61            reason: reason.into(),
62            script: script.into(),
63        }
64    }
65}
66
67/// Errors that can prevent a remediation from applying.
68#[derive(Debug)]
69pub enum RemediationError {
70    /// The script couldn't be parsed (malformed, unknown op, bad arity).
71    /// The underlying string is the same one [`script::parse`] returned.
72    ScriptParse(String),
73    /// A post-state invariant check failed. Carries the human-readable
74    /// violations so callers can report or gate.
75    InvariantViolations(Vec<String>),
76    /// Sexpr emission or parsing hiccup during provenance hashing.
77    /// Shouldn't occur for valid IR values but we surface it rather
78    /// than panicking.
79    Sexpr(SExprError),
80}
81
82impl std::fmt::Display for RemediationError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::ScriptParse(s) => write!(f, "remediation script parse: {s}"),
86            Self::InvariantViolations(v) => {
87                write!(f, "remediation invariant violations: {v:?}")
88            }
89            Self::Sexpr(e) => write!(f, "remediation sexpr: {e}"),
90        }
91    }
92}
93
94impl std::error::Error for RemediationError {}
95
96impl From<SExprError> for RemediationError {
97    fn from(e: SExprError) -> Self {
98        Self::Sexpr(e)
99    }
100}
101
102/// The full outcome of applying a proposal: transformed IR, edit list,
103/// before/after hashes, parsed ops, and the proposal reason.
104///
105/// Every field is intended to be attested — tameshi can Merkle-hash
106/// the Outcome's sexpr form and sekiban can gate admission on a
107/// signature over it.
108#[derive(Debug, Clone)]
109pub struct Outcome {
110    /// The original resource, unchanged.
111    pub before: IacResource,
112    /// The transformed resource.
113    pub after: IacResource,
114    /// Content hash of `before` (hex).
115    pub before_hash: String,
116    /// Content hash of `after` (hex).
117    pub after_hash: String,
118    /// Parsed ops from the script.
119    pub ops: Vec<ResourceOp>,
120    /// Structural edit list from `before` to `after` (derived from
121    /// sexpr_diff).
122    pub edits: Vec<Edit>,
123    /// The proposal reason, preserved for audit.
124    pub proposal_reason: String,
125}
126
127impl Outcome {
128    /// Whether the remediation actually changed anything.
129    #[must_use]
130    pub fn changed(&self) -> bool {
131        self.before_hash != self.after_hash
132    }
133
134    /// Count of structural edits produced.
135    #[must_use]
136    pub fn edit_count(&self) -> usize {
137        self.edits.len()
138    }
139}
140
141/// Post-state invariant — anything a caller wants to assert about the
142/// transformed IR. Returns violations (empty = all hold).
143pub type Invariant = fn(&IacResource) -> Vec<String>;
144
145/// Apply a proposal and return its Outcome. Runs no invariant checks
146/// beyond parse-time.
147///
148/// # Errors
149/// `ScriptParse` if the script fails to parse (malformed, unknown op,
150/// arity mismatch).
151pub fn apply_proposal(
152    resource: &IacResource,
153    proposal: &Proposal,
154) -> Result<Outcome, RemediationError> {
155    apply_proposal_with_invariants(resource, proposal, &[])
156}
157
158/// Apply a proposal and check a list of post-state invariants.
159///
160/// # Errors
161/// - `ScriptParse` if the script fails to parse.
162/// - `InvariantViolations` if any invariant returns non-empty violations.
163pub fn apply_proposal_with_invariants(
164    resource: &IacResource,
165    proposal: &Proposal,
166    invariants: &[Invariant],
167) -> Result<Outcome, RemediationError> {
168    let ops = script::parse(&proposal.script).map_err(RemediationError::ScriptParse)?;
169
170    let before = resource.clone();
171    let before_sexpr = before.to_sexpr();
172    let before_hash = before_sexpr.content_hash().to_hex();
173
174    let after = ops.apply(before.clone());
175    let after_sexpr = after.to_sexpr();
176    let after_hash = after_sexpr.content_hash().to_hex();
177
178    // Compute edit list (structural diff between before/after sexprs).
179    let edits = diff(&before_sexpr, &after_sexpr);
180
181    // Run invariants — any violation blocks the outcome.
182    let mut all_violations = Vec::new();
183    for inv in invariants {
184        all_violations.extend(inv(&after));
185    }
186    if !all_violations.is_empty() {
187        return Err(RemediationError::InvariantViolations(all_violations));
188    }
189
190    Ok(Outcome {
191        before,
192        after,
193        before_hash,
194        after_hash,
195        ops,
196        edits,
197        proposal_reason: proposal.reason.clone(),
198    })
199}
200
201/// Emit an Outcome as a canonical sexpr for attestation.
202///
203/// Shape: `(remediation-outcome (:reason "…") (:before-hash "…")
204/// (:after-hash "…") (:edit-count N) (:op-count N))`.
205///
206/// Does not include the full before/after IR — callers that want that
207/// serialize `.before` and `.after` via their own `to_sexpr` calls. The
208/// canonical form here is the *audit header*: enough to identify which
209/// remediation happened to which IR without embedding the whole IR.
210#[must_use]
211pub fn outcome_sexpr(o: &Outcome) -> SExpr {
212    use crate::sexpr::struct_expr;
213    struct_expr(
214        "remediation-outcome",
215        vec![
216            ("reason", SExpr::String(o.proposal_reason.clone())),
217            ("before-hash", SExpr::String(o.before_hash.clone())),
218            ("after-hash", SExpr::String(o.after_hash.clone())),
219            ("edit-count", SExpr::Integer(o.edit_count() as i64)),
220            (
221                "op-count",
222                SExpr::Integer(i64::try_from(o.ops.len()).unwrap_or(i64::MAX)),
223            ),
224        ],
225    )
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::ir::IacType;
232    use crate::testing::{TestAttributeBuilder, test_resource};
233
234    fn sample() -> IacResource {
235        let mut r = test_resource("widget");
236        r.attributes = vec![
237            TestAttributeBuilder::new("name", IacType::String)
238                .required()
239                .build(),
240            TestAttributeBuilder::new("database_url", IacType::String).build(),
241        ];
242        r
243    }
244
245    #[test]
246    fn apply_proposal_with_valid_script() {
247        let r = sample();
248        let p = Proposal::new(
249            "mark db url sensitive",
250            r#"(mark-sensitive "database_url")"#,
251        );
252        let o = apply_proposal(&r, &p).unwrap();
253        assert!(o.changed());
254        assert_ne!(o.before_hash, o.after_hash);
255        assert_eq!(o.ops.len(), 1);
256        assert!(!o.edits.is_empty());
257        assert_eq!(o.proposal_reason, "mark db url sensitive");
258    }
259
260    #[test]
261    fn apply_proposal_no_op_produces_unchanged_outcome() {
262        let r = sample();
263        let p = Proposal::new(
264            "mark nonexistent sensitive",
265            r#"(mark-sensitive "does_not_exist")"#,
266        );
267        let o = apply_proposal(&r, &p).unwrap();
268        // The op runs but matches no attribute → no real change.
269        assert!(!o.changed());
270        assert_eq!(o.before_hash, o.after_hash);
271        assert!(o.edits.is_empty());
272    }
273
274    #[test]
275    fn apply_proposal_rejects_malformed_script() {
276        let r = sample();
277        let p = Proposal::new("nope", "(mark-sensitive");
278        let err = apply_proposal(&r, &p).unwrap_err();
279        assert!(matches!(err, RemediationError::ScriptParse(_)));
280    }
281
282    #[test]
283    fn apply_proposal_rejects_unknown_op() {
284        let r = sample();
285        let p = Proposal::new("nope", r#"(nuke-everything "x")"#);
286        let err = apply_proposal(&r, &p).unwrap_err();
287        assert!(matches!(err, RemediationError::ScriptParse(s) if s.contains("unknown op")));
288    }
289
290    #[test]
291    fn apply_proposal_with_multiple_ops() {
292        let r = sample();
293        let p = Proposal::new(
294            "set description + sensitive",
295            r#"(set-description "v2") (mark-sensitive "database_url")"#,
296        );
297        let o = apply_proposal(&r, &p).unwrap();
298        assert_eq!(o.ops.len(), 2);
299        assert!(o.changed());
300    }
301
302    #[test]
303    fn content_hashes_are_stable_and_correct() {
304        use crate::sexpr::ToSExpr;
305        let r = sample();
306        let p = Proposal::new("noop", r#"(mark-sensitive "missing")"#);
307        let o = apply_proposal(&r, &p).unwrap();
308        assert_eq!(o.before_hash, r.content_hash().to_hex());
309        assert_eq!(o.after_hash, o.after.content_hash().to_hex());
310    }
311
312    #[test]
313    fn edit_list_reflects_sensitive_flip() {
314        let r = sample();
315        let p = Proposal::new("s", r#"(mark-sensitive "database_url")"#);
316        let o = apply_proposal(&r, &p).unwrap();
317        // The sensitive bit changed on one attribute; diff should
318        // show at least one edit whose path ends in "sensitive".
319        assert!(
320            o.edits.iter().any(|e| e.path().ends_with("sensitive")),
321            "expected a 'sensitive' edit, got {:?}",
322            o.edits,
323        );
324    }
325
326    // ── Invariant checking ─────────────────────────────────────
327
328    fn no_unsensitive_dburl(r: &IacResource) -> Vec<String> {
329        let mut v = Vec::new();
330        for a in &r.attributes {
331            if a.canonical_name == "database_url" && !a.sensitive {
332                v.push("database_url must be sensitive".to_string());
333            }
334        }
335        v
336    }
337
338    #[test]
339    fn invariants_pass_when_remediation_fixes_them() {
340        let r = sample();
341        let p = Proposal::new("fix dburl", r#"(mark-sensitive "database_url")"#);
342        let o = apply_proposal_with_invariants(&r, &p, &[no_unsensitive_dburl]).unwrap();
343        assert!(o.changed());
344    }
345
346    #[test]
347    fn invariants_block_outcome_when_not_met() {
348        let r = sample();
349        // A proposal that does nothing (wrong field name) leaves
350        // database_url unflagged — the invariant should fire.
351        let p = Proposal::new("wrong", r#"(mark-sensitive "other")"#);
352        let err = apply_proposal_with_invariants(&r, &p, &[no_unsensitive_dburl]).unwrap_err();
353        match err {
354            RemediationError::InvariantViolations(v) => {
355                assert_eq!(v.len(), 1);
356                assert!(v[0].contains("database_url"));
357            }
358            other => panic!("expected InvariantViolations, got {other:?}"),
359        }
360    }
361
362    // ── Outcome sexpr (audit header) ───────────────────────────
363
364    #[test]
365    fn outcome_sexpr_has_audit_fields() {
366        let r = sample();
367        let p = Proposal::new("reason", r#"(mark-sensitive "database_url")"#);
368        let o = apply_proposal(&r, &p).unwrap();
369        let s = outcome_sexpr(&o).emit();
370        assert!(s.starts_with("(remediation-outcome"));
371        assert!(s.contains("(:reason \"reason\")"));
372        assert!(s.contains("(:before-hash"));
373        assert!(s.contains("(:after-hash"));
374        assert!(s.contains("(:edit-count"));
375        assert!(s.contains("(:op-count"));
376    }
377
378    #[test]
379    fn outcome_sexpr_is_deterministic() {
380        let r = sample();
381        let p = Proposal::new("x", r#"(mark-sensitive "database_url")"#);
382        let o = apply_proposal(&r, &p).unwrap();
383        assert_eq!(outcome_sexpr(&o).emit(), outcome_sexpr(&o).emit());
384    }
385
386    // ── Error display ──────────────────────────────────────────
387
388    #[test]
389    fn error_displays_script_parse() {
390        let err = RemediationError::ScriptParse("bad".into());
391        assert!(err.to_string().contains("script parse"));
392    }
393
394    #[test]
395    fn error_displays_invariant_violations() {
396        let err = RemediationError::InvariantViolations(vec!["x".into()]);
397        assert!(err.to_string().contains("invariant violations"));
398    }
399
400    // ── Determinism ────────────────────────────────────────────
401
402    #[test]
403    fn apply_proposal_is_deterministic() {
404        let r = sample();
405        let p = Proposal::new(
406            "x",
407            r#"(set-description "new") (mark-sensitive "database_url")"#,
408        );
409        let o1 = apply_proposal(&r, &p).unwrap();
410        let o2 = apply_proposal(&r, &p).unwrap();
411        assert_eq!(o1.before_hash, o2.before_hash);
412        assert_eq!(o1.after_hash, o2.after_hash);
413        assert_eq!(o1.edits, o2.edits);
414    }
415}