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;