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(mode: Mode, outcome: Outcome, ctx: &Context<'_>, explanation: Option<&crate::cst::Explanation>) {
131    if !mode.keeps(outcome) {
132        return;
133    }
134    let Some(path) = log_path() else { return };
135    let entry = build_entry(outcome, ctx, explanation);
136    let Ok(line) = serde_json::to_string(&entry) else { return };
137    append_line(&path, &line);
138}
139
140/// `~/.local/state/safe-chains/log.jsonl`. FIXED — see the design doc: the only wrong locations are
141/// ones a user would have to opt into (a log in the worktree is readable AND writable by the agent),
142/// so not offering the choice is strictly safer than validating it.
143///
144/// `$HOME` unset → `None`, and nothing is logged. Deliberately not `XDG_STATE_HOME`: the agent's
145/// environment reaches the hook, which is the same reason `registry::custom` refuses to honour
146/// `XDG_CONFIG_HOME` for the trust root.
147fn log_path() -> Option<PathBuf> {
148    let home = std::env::var_os("HOME")?;
149    if home.is_empty() {
150        return None;
151    }
152    Some(PathBuf::from(home).join(".local/state/safe-chains/log.jsonl"))
153}
154
155fn build_entry(outcome: Outcome, ctx: &Context<'_>, explanation: Option<&crate::cst::Explanation>) -> Value {
156    let now_ms = unix_millis();
157    let mut digest = Sha256::new();
158    digest.update(ctx.command.as_bytes());
159    let hash: String = digest.finalize().iter().take(4).map(|b| format!("{b:02x}")).collect();
160
161    // The refusal reason rides on the SEGMENT, not the entry. A whole-command `facets` field could
162    // only ever be filled for a single-command entry — the engine resolves one command at a time —
163    // so on a chain, which is the common case, it was null exactly when it was most wanted: the
164    // entry named the failing segment and left "but why" to a manual `--explain`. Resolved per
165    // denied segment instead, which costs nothing on the allowed ones and nothing at all under the
166    // default mode's approval path.
167    let segments: Vec<Value> = explanation
168        .map(|e| {
169            e.segments
170                .iter()
171                .map(|s| {
172                    let allowed = s.verdict.is_allowed();
173                    json!({
174                        "text": s.text,
175                        "verdict": if allowed { "allowed" } else { "denied" },
176                        "culprit": s.culprit,
177                        "facets": if allowed { Value::Null } else { facets_of(&s.text) },
178                    })
179                })
180                .collect()
181        })
182        .unwrap_or_default();
183
184    // An approval owes no triage and no facets: there is no refusal to explain and no registry gap,
185    // and computing either would charge the hot path for something nothing reads.
186    let (triage, unknown) = if outcome == Outcome::Allowed { ("allowed", Vec::new()) } else { triage_of(ctx.command) };
187
188    json!({
189        "schema": SCHEMA,
190        "id": format!("{now_ms}-{hash}"),
191        "at": rfc3339_utc(now_ms),
192        "version": env!("CARGO_PKG_VERSION"),
193        "harness": ctx.harness,
194        "outcome": outcome.as_str(),
195        "level": ctx.level,
196        "command": ctx.command,
197        "cwd": ctx.cwd,
198        "root": ctx.root,
199        "session_id": ctx.session_id,
200        "triage": triage,
201        "unknown_commands": unknown,
202        "segments": segments,
203        "stateful": explanation.is_some_and(|e| e.stateful),
204    })
205}
206
207/// The split that decides what a refusal MEANS: a registry gap we can close with a definition, or a
208/// classification decision to defend or revisit. Reuses `suggest::analyze`, which already computes
209/// exactly this and is otherwise only consumed by `--suggest`.
210fn triage_of(command: &str) -> (&'static str, Vec<String>) {
211    use crate::suggest::Outcome as S;
212    match crate::suggest::analyze(command) {
213        // Reachable when the command classifies allowed on its own but the caller recorded a
214        // non-approval — an abstain, or a ceiling below the command's level. Not a registry gap.
215        S::AlreadyAllowed => ("recognized-but-denied", Vec::new()),
216        S::Unparseable => ("unparseable", Vec::new()),
217        S::RecognizedButDenied { .. } => ("recognized-but-denied", Vec::new()),
218        S::Generated { entries, .. } => ("unknown-command", entries.iter().map(|e| e.name.clone()).collect()),
219    }
220}
221
222/// The resolved facet profile and the clause that refused it — the structured form of what
223/// `--explain` prints. `null` when no resolver claims the command (the legacy classifier decided, so
224/// there are no facets), or when the command is not a single segment.
225fn facets_of(command: &str) -> Value {
226    if crate::cst::explain(command).segments.len() != 1 {
227        return Value::Null;
228    }
229    let Ok(words) = shell_words::split(command) else { return Value::Null };
230    if words.is_empty() {
231        return Value::Null;
232    }
233    let tokens: Vec<crate::parse::Token> = words.into_iter().map(crate::parse::Token::from_raw).collect();
234    let Some(ex) = crate::engine::bridge::explain_profile(&tokens) else {
235        return Value::Null;
236    };
237    let capabilities: Vec<Value> = ex
238        .capabilities
239        .iter()
240        .map(|(because, facets)| {
241            let profile: serde_json::Map<String, Value> = facets
242                .iter()
243                .map(|(name, term)| ((*name).to_string(), Value::String((*term).to_string())))
244                .collect();
245            json!({ "because": because, "profile": profile })
246        })
247        .collect();
248    json!({
249        "capabilities": capabilities,
250        "refused_by": ex.blocked_by.as_ref().map(|(level, mismatch)| json!({
251            "level": level,
252            "clause": mismatch.to_string(),
253        })),
254    })
255}
256
257/// Append one complete line, creating the file `0600` and rotating first if it has grown past the
258/// cap. Every failure is silent by design — see the module header.
259///
260/// The write is a single `write_all` of the whole line to an `O_APPEND` handle. For a REGULAR file
261/// both Linux and macOS hold the inode lock across the write, so concurrent appenders cannot
262/// interleave and no advisory lock is needed. (The `PIPE_BUF` atomicity limit people reach for here
263/// governs pipes and FIFOs, not regular files.) The line is therefore built fully in memory first —
264/// streaming it out in pieces is what would tear it.
265fn append_line(path: &std::path::Path, line: &str) {
266    let Some(dir) = path.parent() else { return };
267    if fs::create_dir_all(dir).is_err() {
268        return;
269    }
270    rotate_if_large(path);
271
272    let mut opts = fs::OpenOptions::new();
273    opts.create(true).append(true);
274    #[cfg(unix)]
275    {
276        use std::os::unix::fs::OpenOptionsExt;
277        // The file holds commands verbatim, credentials included. Owner-only from creation — a
278        // later chmod would leave a window where it was not.
279        opts.mode(0o600);
280    }
281    let Ok(mut file) = opts.open(path) else { return };
282    let mut buf = String::with_capacity(line.len() + 1);
283    buf.push_str(line);
284    buf.push('\n');
285    let _ = file.write_all(buf.as_bytes());
286}
287
288/// Shift the generations down and start a fresh file, the way newsyslog and logrotate do.
289///
290/// Two processes can both decide to rotate at once; the second's renames win and cost at most one
291/// generation of history. Acceptable for a diagnostic, and cheaper than the lock that would prevent
292/// it — the writes themselves are already safe without one (see `append_line`).
293fn rotate_if_large(path: &std::path::Path) {
294    rotate_at(path, ROTATE_AT_BYTES, GENERATIONS);
295}
296
297/// The cap and the count are parameters so the behaviour is testable without writing the real
298/// 16 MB. Rotation is the one path here that DESTROYS data — the oldest generation is unlinked —
299/// so it earns a test more than anything else in the module, and a 16 MB fixture is the kind of
300/// cost that gets a test skipped.
301fn rotate_at(path: &std::path::Path, cap: u64, generations: usize) {
302    let Ok(meta) = fs::metadata(path) else { return };
303    if meta.len() < cap {
304        return;
305    }
306    let nth = |n: usize| path.with_extension(format!("jsonl.{n}"));
307    // Oldest first: `.5` is removed, then `.4` becomes `.5`, and so on, so no rename ever clobbers
308    // a generation that has not been moved out of the way yet.
309    let _ = fs::remove_file(nth(generations));
310    for n in (1..generations).rev() {
311        let _ = fs::rename(nth(n), nth(n + 1));
312    }
313    let _ = fs::rename(path, nth(1));
314}
315
316fn unix_millis() -> u64 {
317    std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH)
319        .map(|d| d.as_millis() as u64)
320        .unwrap_or(0)
321}
322
323/// `1786790461233` → `2026-08-13T23:41:01.233Z`.
324///
325/// Hand-rolled rather than pulling in a date crate: the civil-from-days algorithm is fifteen lines
326/// and fully testable, and a dependency added for one format string is a supply-chain and
327/// license-audit cost the project would carry forever.
328fn rfc3339_utc(ms: u64) -> String {
329    let secs = (ms / 1000) as i64;
330    let millis = ms % 1000;
331    let days = secs.div_euclid(86_400);
332    let tod = secs.rem_euclid(86_400);
333    let (y, m, d) = civil_from_days(days);
334    let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
335    format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
336}
337
338/// Days since the Unix epoch → (year, month, day). Howard Hinnant's `civil_from_days`, which is
339/// exact for the whole representable range and needs no lookup tables.
340fn civil_from_days(z: i64) -> (i64, u32, u32) {
341    let z = z + 719_468;
342    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
343    let doe = (z - era * 146_097) as u64; // [0, 146096]
344    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
345    let y = yoe as i64 + era * 400;
346    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
347    let mp = (5 * doy + 2) / 153; // [0, 11]
348    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
349    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
350    (if m <= 2 { y + 1 } else { y }, m, d)
351}
352
353#[cfg(test)]
354mod tests;