Skip to main content

pushkin_core/
waivers.rs

1//! Waivers + the decision log (spec §15 Phase 5; integration doc §7).
2//! `pushkin/waivers.toml` is an append-only human-plane record: signed,
3//! scoped, expiring. The gate consults it to suppress matching denials;
4//! doctor lints it for stale entries (expired, superseded) and surfaces
5//! contradiction links. Agents cannot write it — `pushkin/` is built-in
6//! gate surface (pipeline).
7
8use globset::Glob;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use thiserror::Error;
12use time::format_description::well_known::Rfc3339;
13use time::{Duration, OffsetDateTime};
14use uuid::Uuid;
15
16use crate::envelope::{CheckResult, Decision};
17
18#[derive(Debug, Error)]
19pub enum WaiverError {
20    #[error("cannot read waivers file: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("waivers file rejected: {0}")]
23    Parse(#[from] toml::de::Error),
24    #[error("waiver serialization failed: {0}")]
25    Emit(#[from] toml::ser::Error),
26    #[error("invalid ttl '{ttl}': use <number><s|m|h|d>, e.g. 2h")]
27    BadTtl { ttl: String },
28    #[error("invalid waiver glob '{glob}': {message}")]
29    BadGlob { glob: String, message: String },
30    #[error("timestamp error: {0}")]
31    Timestamp(#[from] time::error::Format),
32}
33
34/// One signed waiver record. Externally deserialized — unknown fields are
35/// rejected loudly (AGENTS.md serde rule).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct Waiver {
39    pub id: String,
40    pub rule: String,
41    pub path: String,
42    pub reason: String,
43    pub author: String,
44    /// UTC ISO-8601 (RFC 3339) — one convention, documented here.
45    pub granted_at: String,
46    pub expires_at: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub supersedes: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub contradicts: Option<String>,
51}
52
53#[derive(Debug, Default, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct WaiverFile {
56    #[serde(default)]
57    waivers: Vec<Waiver>,
58}
59
60/// What a grant needs from the human plane; `Waiver::grant` adds identity
61/// and timestamps.
62#[derive(Debug)]
63pub struct GrantRequest {
64    pub rule: String,
65    pub path: String,
66    pub reason: String,
67    pub author: String,
68    pub ttl: Duration,
69    pub supersedes: Option<String>,
70    pub contradicts: Option<String>,
71}
72
73impl Waiver {
74    /// Builds a signed record: fresh UUID, `granted_at` = now,
75    /// `expires_at` = now + ttl (temporary by default is the point).
76    ///
77    /// # Errors
78    /// Returns `WaiverError::Timestamp` on RFC 3339 formatting failure.
79    pub fn grant(request: GrantRequest) -> Result<Self, WaiverError> {
80        let now = OffsetDateTime::now_utc();
81        Ok(Self {
82            id: Uuid::new_v4().to_string(),
83            rule: request.rule,
84            path: request.path,
85            reason: request.reason,
86            author: request.author,
87            granted_at: now.format(&Rfc3339)?,
88            expires_at: (now + request.ttl).format(&Rfc3339)?,
89            supersedes: request.supersedes,
90            contradicts: request.contradicts,
91        })
92    }
93}
94
95/// Parses `<number><s|m|h|d>` into a duration.
96///
97/// # Errors
98/// Returns `WaiverError::BadTtl` on any other shape.
99pub fn parse_ttl(ttl: &str) -> Result<Duration, WaiverError> {
100    let bad = || WaiverError::BadTtl {
101        ttl: ttl.to_owned(),
102    };
103    let (digits, unit) = ttl.split_at(ttl.len().saturating_sub(1));
104    let count: i64 = digits.parse().map_err(|_| bad())?;
105    if count < 0 {
106        return Err(bad());
107    }
108    match unit {
109        "s" => Ok(Duration::seconds(count)),
110        "m" => Ok(Duration::minutes(count)),
111        "h" => Ok(Duration::hours(count)),
112        "d" => Ok(Duration::days(count)),
113        _ => Err(bad()),
114    }
115}
116
117/// The loaded waiver set: gate-side matching + doctor-side lint.
118#[derive(Debug, Default)]
119pub struct WaiverSet {
120    entries: Vec<Waiver>,
121}
122
123impl WaiverSet {
124    /// Loads the waiver file; a missing file is an empty set (waivers are
125    /// optional), any other failure is loud.
126    ///
127    /// # Errors
128    /// Returns `WaiverError` on unreadable or malformed content.
129    pub fn load(path: &Path) -> Result<Self, WaiverError> {
130        let text = match std::fs::read_to_string(path) {
131            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
132                return Ok(Self::default())
133            }
134            other => other?,
135        };
136        let file: WaiverFile = toml::from_str(&text)?;
137        Ok(Self {
138            entries: file.waivers,
139        })
140    }
141
142    /// Appends one record to the waiver file (creating file and parent dir
143    /// as needed) and returns the stored entry.
144    ///
145    /// # Errors
146    /// Returns `WaiverError` on read, parse, or write failure.
147    pub fn append(path: &Path, waiver: Waiver) -> Result<Waiver, WaiverError> {
148        // Validate the glob at grant time so a bad scope is a loud error
149        // for the human now, not a silent no-op at gate time.
150        Glob::new(&waiver.path).map_err(|error| WaiverError::BadGlob {
151            glob: waiver.path.clone(),
152            message: error.to_string(),
153        })?;
154        if let Some(parent) = path.parent() {
155            std::fs::create_dir_all(parent)?;
156        }
157        let mut file: WaiverFile = match std::fs::read_to_string(path) {
158            Err(error) if error.kind() == std::io::ErrorKind::NotFound => WaiverFile::default(),
159            other => toml::from_str(&other?)?,
160        };
161        file.waivers.push(waiver.clone());
162        std::fs::write(path, toml::to_string_pretty(&file)?)?;
163        Ok(waiver)
164    }
165
166    #[must_use]
167    pub fn entries(&self) -> &[Waiver] {
168        &self.entries
169    }
170
171    /// True when an unexpired, unsuperseded waiver covers `rule` on `file`.
172    /// The protected-path rule is never waivable — the harness cannot be
173    /// negotiated with (integration doc §11). Matching normalizes
174    /// legacy-prefixed rule ids so a waiver granted pre-rename still
175    /// covers its rule (remediation pass 3, PART B2).
176    #[must_use]
177    pub fn suppresses(&self, rule: &str, file: &str, now: OffsetDateTime) -> bool {
178        let rule = crate::legacy::modern_rule_id(rule);
179        if rule == crate::pipeline::RULE_PROTECTED_PATH
180            || rule == crate::pipeline::RULE_READ_ONLY_PATH
181        {
182            return false;
183        }
184        self.entries.iter().any(|waiver| {
185            crate::legacy::modern_rule_id(&waiver.rule) == rule
186                && !is_expired(waiver, now)
187                && !self.is_superseded(&waiver.id)
188                && glob_matches(&waiver.path, file)
189        })
190    }
191
192    /// `apply` at the current instant — the gate's call site.
193    #[must_use]
194    pub fn apply_now(&self, result: CheckResult) -> CheckResult {
195        self.apply(result, OffsetDateTime::now_utc())
196    }
197
198    /// Drops waived violations from a gate result, recomputing the decision.
199    #[must_use]
200    pub fn apply(&self, mut result: CheckResult, now: OffsetDateTime) -> CheckResult {
201        result
202            .violations
203            .retain(|violation| !self.suppresses(&violation.rule, &violation.file, now));
204        if result.violations.is_empty() {
205            result.decision = Decision::Allow;
206        }
207        result
208    }
209
210    fn is_superseded(&self, id: &str) -> bool {
211        self.entries
212            .iter()
213            .any(|other| other.supersedes.as_deref() == Some(id))
214    }
215
216    /// `lint` at the current instant — doctor's call site.
217    #[must_use]
218    pub fn lint_now(&self) -> Vec<String> {
219        self.lint(OffsetDateTime::now_utc())
220    }
221
222    /// `active` at the current instant — report/statusline call site.
223    #[must_use]
224    pub fn active_now(&self) -> Vec<&Waiver> {
225        self.active(OffsetDateTime::now_utc())
226    }
227
228    /// Waivers currently in force: unexpired and unsuperseded.
229    #[must_use]
230    pub fn active(&self, now: OffsetDateTime) -> Vec<&Waiver> {
231        self.entries
232            .iter()
233            .filter(|waiver| !is_expired(waiver, now) && !self.is_superseded(&waiver.id))
234            .collect()
235    }
236
237    /// Stale-entry lint findings for doctor (spec §15: "stale-entry lint").
238    /// Expired and superseded entries are stale; contradiction links are
239    /// surfaced as informational pairs for the human to resolve.
240    #[must_use]
241    pub fn lint(&self, now: OffsetDateTime) -> Vec<String> {
242        let mut findings = Vec::new();
243        for waiver in &self.entries {
244            if is_expired(waiver, now) {
245                findings.push(format!(
246                    "waiver {} ({} on {}) is expired since {} — stale entry, prune it",
247                    waiver.id, waiver.rule, waiver.path, waiver.expires_at
248                ));
249            }
250            if self.is_superseded(&waiver.id) {
251                findings.push(format!(
252                    "waiver {} ({} on {}) is superseded — stale entry, prune it",
253                    waiver.id, waiver.rule, waiver.path
254                ));
255            }
256            if let Some(target) = &waiver.contradicts {
257                findings.push(format!(
258                    "waiver {} contradicts {} — resolve the pair; both remain on record",
259                    waiver.id, target
260                ));
261            }
262        }
263        findings
264    }
265}
266
267fn is_expired(waiver: &Waiver, now: OffsetDateTime) -> bool {
268    // An unparsable expiry never grants suppression (fail closed).
269    OffsetDateTime::parse(&waiver.expires_at, &Rfc3339).map_or(true, |expires| expires <= now)
270}
271
272fn glob_matches(glob: &str, file: &str) -> bool {
273    Glob::new(glob).is_ok_and(|g| g.compile_matcher().is_match(file))
274}