Skip to main content

safe_chains/
decisionlog.rs

1//! The opt-in decision log: one JSON object per classification, appended to
2//! `~/.local/state/safe-chains/log.jsonl`.
3//!
4//! Off unless the hook is invoked with `--log` (records what did NOT auto-approve) or
5//! `--log-everything` (records approvals too). See `docs/design/decision-log.md`.
6//!
7//! Two properties govern everything here:
8//!
9//! **Logging can never change a verdict.** A `PreToolUse` hook that crashes fails OPEN — the harness
10//! runs the command — so a logging fault must not propagate. Every path in this module swallows its
11//! errors: a missing `$HOME`, an unwritable directory, a full disk and a read-only filesystem all
12//! result in nothing being written and the classification proceeding untouched. There is no `?` that
13//! escapes to the caller and no `unwrap`.
14//!
15//! **The mode is checked before the entry is built.** `--log`'s whole advantage is that it does
16//! nothing on the overwhelmingly common path (an approval), and that is only true if the
17//! allow/deny test comes first. `record` returns before touching the engine, the filesystem or the
18//! clock when the outcome is not one this mode keeps.
19
20use std::fmt;
21use std::fs;
22use std::io::Write;
23use std::path::PathBuf;
24
25use serde_json::{Value, json};
26use sha2::{Digest, Sha256};
27
28/// Rotate once the current file reaches this size, keeping [`GENERATIONS`] older files.
29///
30/// The shape follows the platform conventions rather than a number picked from one machine's usage:
31/// macOS ships `/etc/newsyslog.conf` entries at 1000 KB with a count of 5, and logrotate's own
32/// manual example is `weekly` + `rotate 5`, with `size` given in units like `100k`/`100M`. Both
33/// keep SEVERAL generations; a single old file is the part that was unconventional.
34///
35/// Size-based rather than time-based because safe-chains is a short-lived hook process, not a
36/// daemon with a cron entry — there is nothing to run a weekly job, so the check happens on open.
37///
38/// 16 MB sits in logrotate's usual band for an application log and holds ~22k entries at the
39/// measured 754-byte mean, so the five generations span ~110k decisions. Under `--log` (refusals
40/// only) that is years; under `--log-everything` it is weeks of heavy use.
41const ROTATE_AT_BYTES: u64 = 16 * 1024 * 1024;
42
43/// How many rotated files to keep (`log.jsonl.1` … `log.jsonl.5`), matching newsyslog's count and
44/// logrotate's `rotate 5`.
45const GENERATIONS: usize = 5;
46
47/// Schema version of an entry. Bump on any incompatible change to the field set.
48const SCHEMA: u32 = 1;
49
50/// What the log keeps.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Mode {
53    /// No logging at all — no file is created.
54    Off,
55    /// Everything that did not auto-approve: denials, abstains, parse failures.
56    NonApprovals,
57    /// The above, plus approvals.
58    Everything,
59}
60
61impl Mode {
62    /// `--log` / `--log-everything`, resolved. `--log-everything` wins when both are given: it is
63    /// the strictly wider request, so honouring it cannot lose an entry the user asked for.
64    pub fn from_flags(log: bool, log_everything: bool) -> Self {
65        match (log, log_everything) {
66            (_, true) => Mode::Everything,
67            (true, false) => Mode::NonApprovals,
68            (false, false) => Mode::Off,
69        }
70    }
71
72    /// Whether an entry with this outcome is kept. The gate that must run BEFORE an entry is built.
73    pub fn keeps(self, outcome: Outcome) -> bool {
74        match self {
75            Mode::Off => false,
76            Mode::Everything => true,
77            Mode::NonApprovals => outcome != Outcome::Allowed,
78        }
79    }
80}
81
82/// What safe-chains decided. Distinct from a bare bool because the three non-approvals need telling
83/// apart in triage: a denial is a classification, an abstain is a harness/tool mismatch, and a parse
84/// failure is a availability signal — a rise in them is how a parser regression shows up in the field.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Outcome {
87    Allowed,
88    Denied,
89    Abstained,
90    Unparseable,
91}
92
93impl Outcome {
94    fn as_str(self) -> &'static str {
95        match self {
96            Outcome::Allowed => "allowed",
97            Outcome::Denied => "denied",
98            Outcome::Abstained => "abstained",
99            Outcome::Unparseable => "unparseable",
100        }
101    }
102}
103
104impl fmt::Display for Outcome {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110/// Everything about the invocation that is not the verdict. Borrowed rather than owned so building
111/// one costs nothing on the path where the mode discards it.
112pub struct Context<'a> {
113    pub command: &'a str,
114    pub cwd: Option<&'a str>,
115    pub root: Option<&'a str>,
116    pub session_id: Option<&'a str>,
117    /// The harness whose hook envelope was parsed (`claude`, `codex`, …), or `cli`.
118    pub harness: &'a str,
119    /// The auto-approve ceiling in force, as its level name.
120    pub level: &'a str,
121}
122
123/// Append one entry, if this mode keeps that outcome. Never fails, never panics, never blocks the
124/// caller's decision.
125///
126/// `explanation` supplies the per-segment breakdown. It is optional because the caller only has one
127/// on the paths that computed it: an approval is decided without ever building an explanation, and
128/// making one just to log it would put the cost back on the hot path that `--log` exists to keep
129/// free.
130pub fn record(
131    mode: Mode,
132    outcome: Outcome,
133    ctx: &Context<'_>,
134    explanation: Option<&crate::cst::Explanation>,
135) {
136    if !mode.keeps(outcome) {
137        return;
138    }
139    let Some(path) = log_path() else { return };
140    let entry = build_entry(outcome, ctx, explanation);
141    let Ok(line) = serde_json::to_string(&entry) else { return };
142    append_line(&path, &line);
143}
144
145/// `~/.local/state/safe-chains/log.jsonl`. FIXED — see the design doc: the only wrong locations are
146/// ones a user would have to opt into (a log in the worktree is readable AND writable by the agent),
147/// so not offering the choice is strictly safer than validating it.
148///
149/// `$HOME` unset → `None`, and nothing is logged. Deliberately not `XDG_STATE_HOME`: the agent's
150/// environment reaches the hook, which is the same reason `registry::custom` refuses to honour
151/// `XDG_CONFIG_HOME` for the trust root.
152fn log_path() -> Option<PathBuf> {
153    let home = std::env::var_os("HOME")?;
154    if home.is_empty() {
155        return None;
156    }
157    Some(PathBuf::from(home).join(".local/state/safe-chains/log.jsonl"))
158}
159
160fn build_entry(
161    outcome: Outcome,
162    ctx: &Context<'_>,
163    explanation: Option<&crate::cst::Explanation>,
164) -> Value {
165    let now_ms = unix_millis();
166    let mut digest = Sha256::new();
167    digest.update(ctx.command.as_bytes());
168    let hash: String = digest.finalize().iter().take(4).map(|b| format!("{b:02x}")).collect();
169
170    // The refusal reason rides on the SEGMENT, not the entry. A whole-command `facets` field could
171    // only ever be filled for a single-command entry — the engine resolves one command at a time —
172    // so on a chain, which is the common case, it was null exactly when it was most wanted: the
173    // entry named the failing segment and left "but why" to a manual `--explain`. Resolved per
174    // denied segment instead, which costs nothing on the allowed ones and nothing at all under the
175    // default mode's approval path.
176    let segments: Vec<Value> = explanation
177        .map(|e| {
178            e.segments
179                .iter()
180                .map(|s| {
181                    let allowed = s.verdict.is_allowed();
182                    json!({
183                        "text": s.text,
184                        "verdict": if allowed { "allowed" } else { "denied" },
185                        "culprit": s.culprit,
186                        "facets": if allowed { Value::Null } else { facets_of(&s.text) },
187                    })
188                })
189                .collect()
190        })
191        .unwrap_or_default();
192
193    // An approval owes no triage and no facets: there is no refusal to explain and no registry gap,
194    // and computing either would charge the hot path for something nothing reads.
195    let (triage, unknown) = if outcome == Outcome::Allowed {
196        ("allowed", Vec::new())
197    } else {
198        triage_of(ctx.command)
199    };
200
201    json!({
202        "schema": SCHEMA,
203        "id": format!("{now_ms}-{hash}"),
204        "at": rfc3339_utc(now_ms),
205        "version": env!("CARGO_PKG_VERSION"),
206        "harness": ctx.harness,
207        "outcome": outcome.as_str(),
208        "level": ctx.level,
209        "command": ctx.command,
210        "cwd": ctx.cwd,
211        "root": ctx.root,
212        "session_id": ctx.session_id,
213        "triage": triage,
214        "unknown_commands": unknown,
215        "segments": segments,
216        "stateful": explanation.is_some_and(|e| e.stateful),
217    })
218}
219
220/// The split that decides what a refusal MEANS: a registry gap we can close with a definition, or a
221/// classification decision to defend or revisit. Reuses `suggest::analyze`, which already computes
222/// exactly this and is otherwise only consumed by `--suggest`.
223fn triage_of(command: &str) -> (&'static str, Vec<String>) {
224    use crate::suggest::Outcome as S;
225    match crate::suggest::analyze(command) {
226        // Reachable when the command classifies allowed on its own but the caller recorded a
227        // non-approval — an abstain, or a ceiling below the command's level. Not a registry gap.
228        S::AlreadyAllowed => ("recognized-but-denied", Vec::new()),
229        S::Unparseable => ("unparseable", Vec::new()),
230        S::RecognizedButDenied { .. } => ("recognized-but-denied", Vec::new()),
231        S::Generated { entries, .. } => {
232            ("unknown-command", entries.iter().map(|e| e.name.clone()).collect())
233        }
234    }
235}
236
237/// The resolved facet profile and the clause that refused it — the structured form of what
238/// `--explain` prints. `null` when no resolver claims the command (the legacy classifier decided, so
239/// there are no facets), or when the command is not a single segment.
240fn facets_of(command: &str) -> Value {
241    if crate::cst::explain(command).segments.len() != 1 {
242        return Value::Null;
243    }
244    let Ok(words) = shell_words::split(command) else { return Value::Null };
245    if words.is_empty() {
246        return Value::Null;
247    }
248    let tokens: Vec<crate::parse::Token> =
249        words.into_iter().map(crate::parse::Token::from_raw).collect();
250    let Some(ex) = crate::engine::bridge::explain_profile(&tokens) else {
251        return Value::Null;
252    };
253    let capabilities: Vec<Value> = ex
254        .capabilities
255        .iter()
256        .map(|(because, facets)| {
257            let profile: serde_json::Map<String, Value> = facets
258                .iter()
259                .map(|(name, term)| ((*name).to_string(), Value::String((*term).to_string())))
260                .collect();
261            json!({ "because": because, "profile": profile })
262        })
263        .collect();
264    json!({
265        "capabilities": capabilities,
266        "refused_by": ex.blocked_by.as_ref().map(|(level, mismatch)| json!({
267            "level": level,
268            "clause": mismatch.to_string(),
269        })),
270    })
271}
272
273/// Append one complete line, creating the file `0600` and rotating first if it has grown past the
274/// cap. Every failure is silent by design — see the module header.
275///
276/// The write is a single `write_all` of the whole line to an `O_APPEND` handle. For a REGULAR file
277/// both Linux and macOS hold the inode lock across the write, so concurrent appenders cannot
278/// interleave and no advisory lock is needed. (The `PIPE_BUF` atomicity limit people reach for here
279/// governs pipes and FIFOs, not regular files.) The line is therefore built fully in memory first —
280/// streaming it out in pieces is what would tear it.
281fn append_line(path: &std::path::Path, line: &str) {
282    let Some(dir) = path.parent() else { return };
283    if fs::create_dir_all(dir).is_err() {
284        return;
285    }
286    rotate_if_large(path);
287
288    let mut opts = fs::OpenOptions::new();
289    opts.create(true).append(true);
290    #[cfg(unix)]
291    {
292        use std::os::unix::fs::OpenOptionsExt;
293        // The file holds commands verbatim, credentials included. Owner-only from creation — a
294        // later chmod would leave a window where it was not.
295        opts.mode(0o600);
296    }
297    let Ok(mut file) = opts.open(path) else { return };
298    let mut buf = String::with_capacity(line.len() + 1);
299    buf.push_str(line);
300    buf.push('\n');
301    let _ = file.write_all(buf.as_bytes());
302}
303
304/// Shift the generations down and start a fresh file, the way newsyslog and logrotate do.
305///
306/// Two processes can both decide to rotate at once; the second's renames win and cost at most one
307/// generation of history. Acceptable for a diagnostic, and cheaper than the lock that would prevent
308/// it — the writes themselves are already safe without one (see `append_line`).
309fn rotate_if_large(path: &std::path::Path) {
310    rotate_at(path, ROTATE_AT_BYTES, GENERATIONS);
311}
312
313/// The cap and the count are parameters so the behaviour is testable without writing the real
314/// 16 MB. Rotation is the one path here that DESTROYS data — the oldest generation is unlinked —
315/// so it earns a test more than anything else in the module, and a 16 MB fixture is the kind of
316/// cost that gets a test skipped.
317fn rotate_at(path: &std::path::Path, cap: u64, generations: usize) {
318    let Ok(meta) = fs::metadata(path) else { return };
319    if meta.len() < cap {
320        return;
321    }
322    let nth = |n: usize| path.with_extension(format!("jsonl.{n}"));
323    // Oldest first: `.5` is removed, then `.4` becomes `.5`, and so on, so no rename ever clobbers
324    // a generation that has not been moved out of the way yet.
325    let _ = fs::remove_file(nth(generations));
326    for n in (1..generations).rev() {
327        let _ = fs::rename(nth(n), nth(n + 1));
328    }
329    let _ = fs::rename(path, nth(1));
330}
331
332fn unix_millis() -> u64 {
333    std::time::SystemTime::now()
334        .duration_since(std::time::UNIX_EPOCH)
335        .map(|d| d.as_millis() as u64)
336        .unwrap_or(0)
337}
338
339/// `1786790461233` → `2026-08-13T23:41:01.233Z`.
340///
341/// Hand-rolled rather than pulling in a date crate: the civil-from-days algorithm is fifteen lines
342/// and fully testable, and a dependency added for one format string is a supply-chain and
343/// license-audit cost the project would carry forever.
344fn rfc3339_utc(ms: u64) -> String {
345    let secs = (ms / 1000) as i64;
346    let millis = ms % 1000;
347    let days = secs.div_euclid(86_400);
348    let tod = secs.rem_euclid(86_400);
349    let (y, m, d) = civil_from_days(days);
350    let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
351    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
352}
353
354/// Days since the Unix epoch → (year, month, day). Howard Hinnant's `civil_from_days`, which is
355/// exact for the whole representable range and needs no lookup tables.
356fn civil_from_days(z: i64) -> (i64, u32, u32) {
357    let z = z + 719_468;
358    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
359    let doe = (z - era * 146_097) as u64; // [0, 146096]
360    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
361    let y = yoe as i64 + era * 400;
362    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
363    let mp = (5 * doy + 2) / 153; // [0, 11]
364    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
365    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
366    (if m <= 2 { y + 1 } else { y }, m, d)
367}
368
369#[cfg(test)]
370mod tests;