Skip to main content

doover_core/
hooks.rs

1//! Hook engine: the composition point where harness events become protected
2//! actions. `handle_pre` = parse → resolve scope → snapshot (ALWAYS under
3//! limits) → journal pending; `handle_post` = correlate by tool_use_id →
4//! completed. Contract facts baked in from the live capture (fixtures
5//! README): the harness sends the session's live cwd per call, there are no
6//! exit codes, and failed commands never emit a post event.
7//!
8//! Error philosophy: this library returns honest errors; the BINARY converts
9//! them to fail-open (never block the agent). One exception is snapshotting:
10//! once an action is journaled, per-path snapshot failures degrade to loud
11//! journal notes instead of errors, so partial protection is recorded rather
12//! than discarded.
13
14use crate::journal::{ActionId, Journal, JournalError, ManifestRole, NewAction};
15use crate::registry::Registry;
16use crate::resolver::{Ctx, Severity, resolve};
17use crate::snapshot::{Limits, SnapshotError, Store};
18use std::path::PathBuf;
19
20#[derive(Debug, thiserror::Error)]
21pub enum HookError {
22    #[error("event parse: {0}")]
23    Parse(String),
24    #[error("event is for tool {0}, not Bash")]
25    NotBash(String),
26    #[error(transparent)]
27    Journal(#[from] JournalError),
28    #[error(transparent)]
29    Snapshot(#[from] SnapshotError),
30    #[error("registry: {0}")]
31    Registry(#[from] crate::registry::RegistryError),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum UnknownPolicy {
36    /// Snapshot the working directory (bounded by limits) when any part of
37    /// the command escaped full accounting. The default.
38    SnapshotCwd,
39    /// Journal the gap loudly but snapshot nothing.
40    Passthrough,
41}
42
43/// Default wall-clock budget for a single snapshot. Sized to finish, wrap up,
44/// and journal comfortably inside the harness hook timeout (installed at 20s),
45/// so the loud PARTIAL-coverage gap always wins the race against a SIGKILL
46/// rather than losing it (bench D1).
47const DEFAULT_SNAPSHOT_MS: u64 = 5_000;
48
49fn snapshot_budget() -> Option<std::time::Duration> {
50    parse_snapshot_budget(std::env::var("DOOVER_MAX_SNAPSHOT_MS").ok().as_deref())
51}
52
53/// The snapshot time budget the hook binary will actually run with —
54/// exposed for `doover doctor`, which cross-checks it against the installed
55/// hook timeout (a budget at/above the timeout re-creates the SIGKILL blind
56/// spot the budget exists to close).
57pub fn effective_snapshot_budget() -> Option<std::time::Duration> {
58    snapshot_budget()
59}
60
61/// Parse `DOOVER_MAX_SNAPSHOT_MS`, fail-safe. Unset or unparseable → the 5s
62/// default; an explicit `0` → no budget (unlimited, the documented opt-out).
63/// Garbage never silently reduces protection to nothing.
64fn parse_snapshot_budget(v: Option<&str>) -> Option<std::time::Duration> {
65    let default = std::time::Duration::from_millis(DEFAULT_SNAPSHOT_MS);
66    match v {
67        None => Some(default),
68        Some(s) => match s.trim().parse::<u64>() {
69            Ok(0) => None,
70            Ok(ms) => Some(std::time::Duration::from_millis(ms)),
71            Err(_) => Some(default),
72        },
73    }
74}
75
76/// Build/dependency directories that a build command recreates from source.
77/// Dogfooding a real Rust repo: `target/` was 166,391 of 167,292 files (99.5%)
78/// and 6.7 GB — a defensive snapshot spent its entire 5s budget on artifacts
79/// and captured 8% of the tree, almost none of it the user's actual code.
80/// Skipping these keeps the budget on what matters. Override with
81/// DOOVER_SKIP_DIRS (comma-separated; empty string = skip nothing).
82const DEFAULT_SKIP_DIRS: &[&str] = &[
83    "target",       // rust, maven
84    "node_modules", // node
85    "__pycache__",
86    ".venv",
87    "venv",
88    ".mypy_cache",
89    ".pytest_cache",
90    ".tox",
91    "dist",
92    "build",
93    ".next",
94    ".nuxt",
95    ".svelte-kit",
96    ".gradle",
97    ".terraform",
98    ".cargo",
99    "vendor", // go/php dependency vendoring
100    ".parcel-cache",
101    ".turbo",
102];
103
104/// The directory names a snapshot may skip. Never applies to an explicitly
105/// targeted path, and (inside a git repo) only to directories git ignores.
106pub fn skip_dir_names() -> Vec<String> {
107    match std::env::var("DOOVER_SKIP_DIRS") {
108        Ok(v) => v
109            .split(',')
110            .map(|s| s.trim())
111            .filter(|s| !s.is_empty())
112            .map(String::from)
113            .collect(),
114        Err(_) => DEFAULT_SKIP_DIRS.iter().map(|s| s.to_string()).collect(),
115    }
116}
117
118/// The skip policy for a snapshot rooted at `path`: the name list, gated by
119/// the enclosing repository's gitignore so a build-NAMED directory that git
120/// actually tracks (real source in `build/`, a committed Go `vendor/`) is
121/// still captured.
122pub fn skip_policy_for(path: &std::path::Path) -> crate::snapshot::SkipPolicy {
123    let repo = crate::resolver::find_repo_root(path);
124    crate::snapshot::SkipPolicy::new(skip_dir_names(), repo.as_deref())
125}
126
127/// Create DOOVER_HOME if needed and force it to 0700 (D4): the journal holds
128/// plaintext commands (which may embed secrets) and the store holds copies of
129/// user files — on a shared host neither may be readable by other users. The
130/// explicit chmod is umask-proof and also TIGHTENS a pre-existing loose home
131/// from an older install on the next run.
132pub fn ensure_private_home(path: &std::path::Path) -> std::io::Result<()> {
133    std::fs::create_dir_all(path)?;
134    #[cfg(unix)]
135    {
136        use std::os::unix::fs::PermissionsExt;
137        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
138    }
139    Ok(())
140}
141
142pub struct HookConfig {
143    /// Store + journal + user registry overlay live here (default ~/.doover).
144    pub doover_home: PathBuf,
145    /// The user's home, for tilde resolution (the hook process's own $HOME).
146    pub home: PathBuf,
147    /// Applied to EVERY snapshot — known-destructive scopes included
148    /// (carried-forward requirement; `rm -rf huge/` must not stall the hook
149    /// unboundedly).
150    pub limits: Limits,
151    pub unknown_policy: UnknownPolicy,
152    /// Store budgets + the automatic gc cadence (D2). The post hook runs gc
153    /// when the cadence or free-space floor fires, so the store self-bounds
154    /// without a cron.
155    pub maintenance: crate::maintenance::MaintenanceBudget,
156}
157
158impl HookConfig {
159    /// Environment-driven config for the binary: DOOVER_HOME,
160    /// DOOVER_MAX_FILES, DOOVER_MAX_BYTES, DOOVER_UNKNOWN_POLICY.
161    pub fn from_env() -> Self {
162        let home = std::env::var_os("HOME")
163            .map(PathBuf::from)
164            .unwrap_or_else(|| PathBuf::from("/"));
165        let doover_home = std::env::var_os("DOOVER_HOME")
166            .map(PathBuf::from)
167            .unwrap_or_else(|| home.join(".doover"));
168        let env_u64 = |k: &str, default: u64| {
169            std::env::var(k)
170                .ok()
171                .and_then(|v| v.parse().ok())
172                .unwrap_or(default)
173        };
174        let unknown_policy = match std::env::var("DOOVER_UNKNOWN_POLICY").as_deref() {
175            Ok("passthrough") => UnknownPolicy::Passthrough,
176            _ => UnknownPolicy::SnapshotCwd,
177        };
178        Self {
179            doover_home,
180            home,
181            limits: Limits {
182                max_files: env_u64("DOOVER_MAX_FILES", 100_000),
183                max_bytes: env_u64("DOOVER_MAX_BYTES", 5 * 1024 * 1024 * 1024),
184                max_duration: snapshot_budget(),
185            },
186            unknown_policy,
187            maintenance: crate::maintenance::MaintenanceBudget::from_env(),
188        }
189    }
190}
191
192#[derive(Debug)]
193pub struct PreEvent {
194    pub session_id: String,
195    pub tool_use_id: String,
196    pub tool_name: String,
197    pub cwd: PathBuf,
198    pub command: String,
199}
200
201#[derive(Debug)]
202pub struct PostEvent {
203    pub session_id: String,
204    pub tool_use_id: String,
205    pub tool_name: String,
206    pub duration_ms: i64,
207}
208
209mod wire {
210    #[derive(serde::Deserialize)]
211    pub struct ToolInput {
212        pub command: Option<String>,
213    }
214    #[derive(serde::Deserialize)]
215    pub struct Event {
216        pub session_id: String,
217        pub cwd: String,
218        pub tool_name: String,
219        pub tool_use_id: String,
220        pub tool_input: Option<ToolInput>,
221        pub duration_ms: Option<i64>,
222    }
223}
224
225/// One wall-clock deadline shared by EVERY snapshot in a single hook
226/// invocation (round 19): N targets must never stack N budgets past the
227/// harness timeout — that would re-create the SIGKILL blind spot the D1
228/// budget exists to close. Each call gets the time remaining; at/after the
229/// deadline a zero budget makes the snapshot truncate immediately, which the
230/// existing machinery journals as a loud gap. A `None` base budget (explicit
231/// opt-out) stays unlimited.
232fn slice_limits(base: &Limits, deadline: Option<std::time::Instant>) -> Limits {
233    let mut l = *base;
234    if let Some(dl) = deadline {
235        l.max_duration = Some(dl.saturating_duration_since(std::time::Instant::now()));
236    }
237    l
238}
239
240fn hook_deadline(limits: &Limits) -> Option<std::time::Instant> {
241    limits.max_duration.map(|d| std::time::Instant::now() + d)
242}
243
244pub fn parse_pre_event(json: &str) -> Result<PreEvent, HookError> {
245    let e: wire::Event = serde_json::from_str(json).map_err(|e| HookError::Parse(e.to_string()))?;
246    if e.tool_name != "Bash" {
247        return Err(HookError::NotBash(e.tool_name));
248    }
249    let command = e
250        .tool_input
251        .and_then(|t| t.command)
252        .ok_or_else(|| HookError::Parse("missing tool_input.command".into()))?;
253    Ok(PreEvent {
254        session_id: e.session_id,
255        tool_use_id: e.tool_use_id,
256        tool_name: "Bash".into(),
257        cwd: PathBuf::from(e.cwd),
258        command,
259    })
260}
261
262pub fn parse_post_event(json: &str) -> Result<PostEvent, HookError> {
263    let e: wire::Event = serde_json::from_str(json).map_err(|e| HookError::Parse(e.to_string()))?;
264    if e.tool_name != "Bash" {
265        return Err(HookError::NotBash(e.tool_name));
266    }
267    Ok(PostEvent {
268        session_id: e.session_id,
269        tool_use_id: e.tool_use_id,
270        tool_name: "Bash".into(),
271        // the contract has no exit code; duration is the only post metric
272        duration_ms: e.duration_ms.unwrap_or(0),
273    })
274}
275
276/// Outcome summary for logging and, crucially, for the binary's runtime
277/// warning. `gaps` holds the loud protection-gap messages (snapshot failures,
278/// truncations) — non-empty means coverage is incomplete. The binary warns
279/// when a destructive+ action has gaps, so "I ran but couldn't fully protect
280/// you" is never silent (audit round 9).
281pub struct PreOutcome {
282    pub action_id: ActionId,
283    pub manifests_attached: usize,
284    pub severity: Severity,
285    pub gaps: Vec<String>,
286}
287
288impl PreOutcome {
289    /// True when the binary should emit a loud (but non-blocking) warning: any
290    /// protection gap at all. `gaps` is only ever populated when a snapshot was
291    /// ATTEMPTED (a destructive scope, or the defensive cwd snapshot for an
292    /// unknown command) and it failed or truncated — so a non-empty `gaps`
293    /// always means "we tried to protect you and couldn't fully." Gating this
294    /// on `severity >= Destructive` (as the first cut did) wrongly silenced the
295    /// unknown path, which is exactly where we defend BECAUSE the command might
296    /// be destructive. Safe/mutating commands never snapshot, so never warn.
297    pub fn needs_warning(&self) -> bool {
298        !self.gaps.is_empty()
299    }
300}
301
302fn open_journal(cfg: &HookConfig) -> Result<Journal, HookError> {
303    ensure_private_home(&cfg.doover_home).map_err(|e| {
304        HookError::Parse(format!(
305            "cannot create doover home {}: {e}",
306            cfg.doover_home.display()
307        ))
308    })?;
309    Ok(Journal::open(&cfg.doover_home.join("journal.db"))?)
310}
311
312pub fn handle_pre(cfg: &HookConfig, ev: &PreEvent) -> Result<PreOutcome, HookError> {
313    let journal = open_journal(cfg)?;
314    journal.begin_session(&ev.session_id, "claude-code", &ev.cwd.to_string_lossy())?;
315
316    let (registry, overlay_warnings) = Registry::with_overlay(&cfg.doover_home.join("registry.d"))?;
317    for w in &overlay_warnings {
318        eprintln!("doover: registry overlay: {w}");
319    }
320
321    let ctx = Ctx {
322        cwd: &ev.cwd,
323        home: &cfg.home,
324    };
325    let r = resolve(&ev.command, &registry, &ctx);
326
327    let action = journal.start_action(&NewAction {
328        session_id: &ev.session_id,
329        tool_use_id: Some(&ev.tool_use_id),
330        raw_command: &ev.command,
331        effect: r.severity.as_str(),
332        rule_id: r.rule_id.as_deref(),
333        has_unknown: r.has_unknown,
334    })?;
335
336    // snapshot destructive+ scopes; the unknown policy adds a bounded cwd
337    // snapshot when anything escaped accounting
338    let mut targets: Vec<PathBuf> = Vec::new();
339    if r.severity >= Severity::Destructive {
340        targets.extend(r.paths.iter().cloned());
341    }
342    if r.has_unknown && cfg.unknown_policy == UnknownPolicy::SnapshotCwd {
343        let cwd = crate::resolver::normalize_lexical(&ev.cwd);
344        if !targets.contains(&cwd) {
345            targets.push(cwd);
346        }
347    }
348
349    let mut attached = 0usize;
350    let mut gaps: Vec<String> = Vec::new();
351    // record a gap both in the journal (for `log`) and in the outcome (for
352    // the binary's runtime warning)
353    let mut note_gap = |journal: &Journal, msg: String| -> Result<(), HookError> {
354        journal.add_note(action, &msg)?;
355        gaps.push(msg);
356        Ok(())
357    };
358    if !targets.is_empty() {
359        let store = Store::open(cfg.doover_home.join("store"))?;
360        let deadline = hook_deadline(&cfg.limits);
361        for path in &targets {
362            let skips = skip_policy_for(path);
363            // once the action exists, per-path failures become loud gaps,
364            // never lost protection for the OTHER paths — and never silent
365            match store.snapshot_scoped(
366                path,
367                Some(&slice_limits(&cfg.limits, deadline)),
368                std::slice::from_ref(&cfg.doover_home),
369                &skips,
370            ) {
371                Ok(manifest) => {
372                    if manifest.truncated {
373                        note_gap(
374                            &journal,
375                            format!(
376                                "UNPROTECTED: snapshot of {} truncated at limits ({} files skipped)",
377                                path.display(),
378                                manifest.skipped
379                            ),
380                        )?;
381                    }
382                    if !manifest.warnings.is_empty() {
383                        note_gap(
384                            &journal,
385                            format!(
386                                "PARTIAL: snapshot gaps at {}: {}",
387                                path.display(),
388                                manifest.warnings.join("; ")
389                            ),
390                        )?;
391                    }
392                    journal.attach_manifest(action, &manifest, ManifestRole::Pre)?;
393                    attached += 1;
394                }
395                Err(e) => {
396                    note_gap(
397                        &journal,
398                        format!("UNPROTECTED: snapshot of {} failed: {e}", path.display()),
399                    )?;
400                }
401            }
402        }
403    }
404
405    Ok(PreOutcome {
406        action_id: action,
407        manifests_attached: attached,
408        severity: r.severity,
409        gaps,
410    })
411}
412
413pub fn handle_post(cfg: &HookConfig, ev: &PostEvent) -> Result<ActionId, HookError> {
414    let journal = open_journal(cfg)?;
415    let action = journal.complete_by_tool_use(&ev.session_id, &ev.tool_use_id, ev.duration_ms)?;
416
417    // capture POST state for every path we pre-snapshotted: it is what redo
418    // restores, and the conflict oracle for undo ("is the world still as the
419    // action left it?"). Failures degrade to journal notes — undo still works
420    // from the pre-manifests, just without conflict verification.
421    let pre = journal.manifests_by_role(action, ManifestRole::Pre)?;
422    if !pre.is_empty() {
423        let store = Store::open(cfg.doover_home.join("store"))?;
424        let deadline = hook_deadline(&cfg.limits);
425        for m in &pre {
426            let skips = skip_policy_for(&m.path);
427            match store.snapshot_scoped(
428                &m.path,
429                Some(&slice_limits(&cfg.limits, deadline)),
430                std::slice::from_ref(&cfg.doover_home),
431                &skips,
432            ) {
433                Ok(post) => journal.attach_manifest(action, &post, ManifestRole::Post)?,
434                Err(e) => journal.add_note(
435                    action,
436                    &format!(
437                        "post-state snapshot of {} failed: {e} (redo/conflict checks unavailable)",
438                        m.path.display()
439                    ),
440                )?,
441            }
442        }
443    }
444    maybe_gc(cfg, &journal, action);
445    Ok(action)
446}
447
448/// How long a free-space breach waits between triggered passes. A low disk
449/// that doover cannot fix (someone else's data, CoW-shared blocks) must not
450/// re-run a full gc on every single action.
451const FREE_LOW_RETRIGGER_SECS: u64 = 600;
452
453/// Automatic gc from the post hook (D2): the store must self-bound without a
454/// cron. STRICTLY fail-open — the action already completed; no maintenance
455/// failure may surface to the harness.
456///
457/// Scope is deliberately narrower than manual `doover gc` (D2 review):
458/// - `gc_every == 0` disables ALL automatic gc, free-space path included;
459/// - the free-space floor triggers a retention+cap pass (rate-limited) and a
460///   loud warning, but NEVER deficit-driven eviction — destroying history
461///   over disk pressure that is usually not doover's fault (and frees ~0
462///   physical bytes on CoW) is a decision only an explicit `doover gc` makes;
463/// - the pass carries a wall-clock budget so it stays off the critical path;
464/// - anything evicted (or an unmeetable budget) is journaled on the
465///   triggering action and warned to stderr — never silent.
466fn maybe_gc(cfg: &HookConfig, journal: &Journal, action: ActionId) {
467    let b = cfg.maintenance;
468    if b.gc_every == 0 {
469        return; // full opt-out of automatic maintenance
470    }
471    let cadence_due = action % (b.gc_every as i64).max(1) == 0;
472    let free_low = b
473        .min_free_bytes
474        .zip(crate::snapshot::free_bytes(&cfg.doover_home))
475        .is_some_and(|(floor, free)| free < floor);
476    let free_low_due = free_low && free_low_retrigger_elapsed(cfg);
477    if !cadence_due && !free_low_due {
478        return;
479    }
480    let Ok(store) = Store::open(cfg.doover_home.join("store")) else {
481        return;
482    };
483    let report = crate::maintenance::gc(
484        journal,
485        &store,
486        &cfg.doover_home,
487        &crate::maintenance::auto_gc_options(&b),
488    );
489    touch_gc_marker(cfg);
490    let Ok(report) = report else { return };
491
492    // visibility: history removal is never silent (D2 review, critical)
493    if report.cap_evicted_actions > 0 || report.still_over_budget {
494        let note = format!(
495            "auto-gc: evicted {} old action(s) to satisfy the store size cap{}",
496            report.cap_evicted_actions,
497            if report.still_over_budget {
498                "; STILL over budget (bounded by pins/recent actions — run `doover gc`)"
499            } else {
500                ""
501            }
502        );
503        let _ = journal.add_note(action, &note);
504        eprintln!("doover: {note}");
505    }
506    if free_low {
507        eprintln!(
508            "doover: disk space is low (below the DOOVER_MIN_FREE_BYTES floor); \
509             doover's store is bounded by its size cap — run `doover gc` to review \
510             or evict more history"
511        );
512    }
513}
514
515/// Rate limit for the free-space trigger, via the mtime of a marker file.
516/// Fail-open in the triggering direction: no marker (first run) or an
517/// unreadable one means "due".
518fn free_low_retrigger_elapsed(cfg: &HookConfig) -> bool {
519    let marker = cfg.doover_home.join(".last-auto-gc");
520    match std::fs::metadata(&marker).and_then(|m| m.modified()) {
521        Ok(at) => std::time::SystemTime::now()
522            .duration_since(at)
523            .map(|age| age.as_secs() >= FREE_LOW_RETRIGGER_SECS)
524            .unwrap_or(true),
525        Err(_) => true,
526    }
527}
528
529fn touch_gc_marker(cfg: &HookConfig) {
530    let _ = std::fs::write(cfg.doover_home.join(".last-auto-gc"), b"");
531}
532
533#[cfg(test)]
534mod budget_tests {
535    use super::{DEFAULT_SNAPSHOT_MS, parse_snapshot_budget};
536    use std::time::Duration;
537
538    #[test]
539    fn budget_parse_is_fail_safe() {
540        let default = Some(Duration::from_millis(DEFAULT_SNAPSHOT_MS));
541        // unset and garbage both fall to the safe default — never off
542        assert_eq!(parse_snapshot_budget(None), default);
543        assert_eq!(parse_snapshot_budget(Some("nonsense")), default);
544        assert_eq!(parse_snapshot_budget(Some("-5")), default);
545        assert_eq!(parse_snapshot_budget(Some("")), default);
546        // explicit 0 is the documented "no budget" opt-out
547        assert_eq!(parse_snapshot_budget(Some("0")), None);
548        // a real value is honored (whitespace tolerated)
549        assert_eq!(
550            parse_snapshot_budget(Some("2500")),
551            Some(Duration::from_millis(2500))
552        );
553        assert_eq!(
554            parse_snapshot_budget(Some("  8000 ")),
555            Some(Duration::from_millis(8000))
556        );
557    }
558}