Skip to main content

corcept_runtime/
lib.rs

1use anyhow::{Context, Result};
2use corcept_contract::validate_value;
3use corcept_doctrine::{default_documents, validate as validate_doctrine};
4use corcept_guards::{
5    evaluate_pre_tool, evaluate_stop, extract_command, extract_path, StopVerdict,
6};
7use corcept_ledger::{ensure_ledger, read_events, verify_hash_chain_readonly};
8use corcept_memory::ensure_dirs as ensure_memory_dirs;
9use corcept_sink::{build_ledger_event, SinkDispatcher, SinkRecord};
10use corcept_types::{
11    dir_permissions_secure, operator_data_dir, project_ledger_dir, transition_for, AuthorityLevel,
12    CorceptConfig, HookEnvelope, HookOutput, LedgerEventKind,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct InitOptions {
22    pub path: PathBuf,
23    pub dry_run: bool,
24    pub force: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct InitReport {
29    pub path: PathBuf,
30    pub dry_run: bool,
31    pub created: Vec<PathBuf>,
32    pub modified: Vec<PathBuf>,
33    pub skipped: Vec<PathBuf>,
34    pub warnings: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CheckResult {
39    pub name: String,
40    pub status: String,
41    pub detail: String,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct DoctorOptions {
46    #[serde(default)]
47    pub validate_perms: bool,
48    #[serde(default)]
49    pub strict: bool,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DoctorReport {
54    pub status: String,
55    pub checks: Vec<CheckResult>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AuditReport {
60    pub status: String,
61    pub event_count: usize,
62    pub hash_chain_valid: bool,
63    pub last_event: Option<String>,
64    pub warnings: Vec<String>,
65}
66
67pub fn init_project(options: InitOptions) -> Result<InitReport> {
68    let root = options.path;
69    let mut report = InitReport {
70        path: root.clone(),
71        dry_run: options.dry_run,
72        created: vec![],
73        modified: vec![],
74        skipped: vec![],
75        warnings: vec![],
76    };
77
78    plan_dir(&root.join(".claude"), &mut report, options.dry_run)?;
79    plan_dir(
80        &root.join(".corcept").join("doctrine"),
81        &mut report,
82        options.dry_run,
83    )?;
84    plan_dir(
85        &root.join(".corcept").join("memory").join("accepted"),
86        &mut report,
87        options.dry_run,
88    )?;
89    plan_dir(
90        &root.join(".corcept").join("memory").join("candidates"),
91        &mut report,
92        options.dry_run,
93    )?;
94    plan_dir(
95        &root.join(".corcept").join("memory").join("rejected"),
96        &mut report,
97        options.dry_run,
98    )?;
99    plan_dir(
100        &root.join(".corcept").join("ledger"),
101        &mut report,
102        options.dry_run,
103    )?;
104    plan_dir(
105        &root.join(".corcept").join("reports"),
106        &mut report,
107        options.dry_run,
108    )?;
109
110    write_file(
111        &root.join(".corcept").join("config.yaml"),
112        &serde_yaml::to_string(&CorceptConfig::default())?,
113        options.force,
114        options.dry_run,
115        &mut report,
116    )?;
117    write_file(
118        &root.join(".claude").join("CLAUDE.md"),
119        render_claude_md(),
120        options.force,
121        options.dry_run,
122        &mut report,
123    )?;
124    write_file(
125        &root.join(".claude").join("settings.json"),
126        &render_project_settings()?,
127        options.force,
128        options.dry_run,
129        &mut report,
130    )?;
131
132    for (name, content) in default_documents() {
133        write_file(
134            &root.join(".corcept").join("doctrine").join(name),
135            content,
136            options.force,
137            options.dry_run,
138            &mut report,
139        )?;
140    }
141
142    write_file(
143        &root
144            .join(".corcept")
145            .join("memory")
146            .join("accepted")
147            .join("README.md"),
148        "# Accepted Memory\n\nApproved project memory lives here.\n",
149        options.force,
150        options.dry_run,
151        &mut report,
152    )?;
153    write_file(
154        &root
155            .join(".corcept")
156            .join("memory")
157            .join("candidates")
158            .join("README.md"),
159        "# Candidate Memory\n\nEvidence-backed proposed memories live here until promoted.\n",
160        options.force,
161        options.dry_run,
162        &mut report,
163    )?;
164    write_file(
165        &root
166            .join(".corcept")
167            .join("memory")
168            .join("rejected")
169            .join("README.md"),
170        "# Rejected Memory\n\nRejected or superseded memory candidates live here.\n",
171        options.force,
172        options.dry_run,
173        &mut report,
174    )?;
175
176    if !options.dry_run {
177        ensure_ledger(&root)?;
178        ensure_memory_dirs(&root)?;
179    } else {
180        report
181            .created
182            .push(root.join(".corcept").join("ledger").join("events.jsonl"));
183    }
184
185    Ok(report)
186}
187
188fn plan_dir(path: &Path, report: &mut InitReport, dry_run: bool) -> Result<()> {
189    if path.exists() {
190        return Ok(());
191    }
192    report.created.push(path.to_path_buf());
193    if !dry_run {
194        fs::create_dir_all(path)
195            .with_context(|| format!("creating directory {}", path.display()))?;
196    }
197    Ok(())
198}
199
200fn write_file(
201    path: &Path,
202    content: &str,
203    force: bool,
204    dry_run: bool,
205    report: &mut InitReport,
206) -> Result<()> {
207    if path.exists() && !force {
208        report.skipped.push(path.to_path_buf());
209        return Ok(());
210    }
211    if path.exists() {
212        report.modified.push(path.to_path_buf());
213    } else {
214        report.created.push(path.to_path_buf());
215    }
216    if !dry_run {
217        if let Some(parent) = path.parent() {
218            fs::create_dir_all(parent)?;
219        }
220        fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
221    }
222    Ok(())
223}
224
225pub fn load_config(root: impl AsRef<Path>) -> Result<CorceptConfig> {
226    let path = root.as_ref().join(".corcept").join("config.yaml");
227    if !path.exists() {
228        return Ok(CorceptConfig::default());
229    }
230    let raw =
231        fs::read_to_string(&path).with_context(|| format!("reading config {}", path.display()))?;
232    Ok(serde_yaml::from_str(&raw)?)
233}
234
235pub fn doctor(path: impl AsRef<Path>) -> Result<DoctorReport> {
236    doctor_with_options(path, DoctorOptions::default())
237}
238
239pub fn doctor_with_options(path: impl AsRef<Path>, options: DoctorOptions) -> Result<DoctorReport> {
240    let root = path.as_ref();
241    let mut checks = Vec::new();
242    push_check(
243        &mut checks,
244        "config",
245        root.join(".corcept/config.yaml").exists(),
246        "Project config exists",
247    );
248    push_check(
249        &mut checks,
250        "claude_md",
251        root.join(".claude/CLAUDE.md").exists(),
252        "Claude project instructions exist",
253    );
254    push_check(
255        &mut checks,
256        "ledger",
257        root.join(".corcept/ledger/events.jsonl").exists(),
258        "Ledger exists",
259    );
260    push_check(
261        &mut checks,
262        "memory",
263        root.join(".corcept/memory").exists(),
264        "Memory directory exists",
265    );
266
267    let doctrine_warnings = validate_doctrine(root)
268        .unwrap_or_else(|err| vec![format!("Doctrine validation failed: {err}")]);
269    push_check(
270        &mut checks,
271        "doctrine",
272        doctrine_warnings.is_empty(),
273        if doctrine_warnings.is_empty() {
274            "Doctrine validates"
275        } else {
276            "Doctrine warnings present"
277        },
278    );
279
280    let hash_valid = verify_hash_chain_readonly(root).unwrap_or(false);
281    push_check(
282        &mut checks,
283        "ledger_hash_chain",
284        hash_valid,
285        "Ledger hash chain verifies",
286    );
287
288    if options.strict {
289        let schema_ok = validate_ledger_schema(root);
290        push_check(
291            &mut checks,
292            "ledger_schema",
293            schema_ok,
294            "Ledger events validate against corcept.ledger_event.v1 schema",
295        );
296    }
297
298    if options.validate_perms {
299        let ledger_dir = project_ledger_dir(root);
300        let ledger_perms = dir_permissions_secure(&ledger_dir);
301        push_check(
302            &mut checks,
303            "ledger_dir_perms",
304            ledger_perms,
305            "Project ledger directory is owner-only (0700) or absent",
306        );
307        if let Some(op_data) = operator_data_dir() {
308            if op_data.exists() {
309                let op_perms = dir_permissions_secure(&op_data);
310                push_check(
311                    &mut checks,
312                    "operator_data_dir_perms",
313                    op_perms,
314                    "Operator data directory is owner-only (0700)",
315                );
316            }
317        }
318    }
319
320    let all_pass = checks.iter().all(|check| check.status == "pass");
321    let status = if all_pass {
322        "pass"
323    } else if options.strict {
324        "fail"
325    } else {
326        "warn"
327    }
328    .to_string();
329    Ok(DoctorReport { status, checks })
330}
331
332fn validate_ledger_schema(root: &Path) -> bool {
333    let Ok(events) = read_events(root) else {
334        return false;
335    };
336    for event in events {
337        let Ok(value) = serde_json::to_value(&event) else {
338            return false;
339        };
340        if validate_value("corcept-ledger-event-v1.schema.json", &value).is_err() {
341            return false;
342        }
343    }
344    true
345}
346
347fn push_check(checks: &mut Vec<CheckResult>, name: &str, pass: bool, detail: &str) {
348    checks.push(CheckResult {
349        name: name.to_string(),
350        status: if pass { "pass" } else { "warn" }.to_string(),
351        detail: detail.to_string(),
352    });
353}
354
355pub fn audit(path: impl AsRef<Path>) -> Result<AuditReport> {
356    let events = read_events(&path).unwrap_or_default();
357    let hash_chain_valid = verify_hash_chain_readonly(&path).unwrap_or(false);
358    let mut warnings = Vec::new();
359    if !hash_chain_valid {
360        warnings.push("Ledger hash chain is invalid or ledger is missing.".to_string());
361    }
362    let last_event = events.last().map(|event| event.event_type.clone());
363    Ok(AuditReport {
364        status: if warnings.is_empty() { "pass" } else { "warn" }.to_string(),
365        event_count: events.len(),
366        hash_chain_valid,
367        last_event,
368        warnings,
369    })
370}
371
372pub fn handle_hook(raw_json: &str, command: &str) -> Result<HookOutput> {
373    let input: HookEnvelope = serde_json::from_str(raw_json).context("parsing hook input JSON")?;
374    let cwd = input.cwd.clone().unwrap_or(std::env::current_dir()?);
375    let config = load_config(&cwd).unwrap_or_default();
376
377    match command {
378        "session-start" => {
379            append_hook_event(
380                &cwd,
381                &input,
382                "session-start",
383                LedgerEventKind::SessionStarted,
384                AuthorityLevel::L0Observe,
385                None,
386                Some("allow"),
387                Some("Session started"),
388            )?;
389            Ok(HookOutput::context("SessionStart", "CORCEPT active: doctrine, memory, guard, and audit policy loaded. Use CORCEPT skills for structured workflows."))
390        }
391        "user-prompt-submit" => {
392            let prompt = input.prompt.clone().unwrap_or_default();
393            append_hook_event(
394                &cwd,
395                &input,
396                "user-prompt-submit",
397                LedgerEventKind::PromptSubmitted,
398                AuthorityLevel::L0Observe,
399                None,
400                Some("allow"),
401                Some("Prompt received"),
402            )?;
403            let context = classify_prompt_context(&prompt);
404            Ok(HookOutput::context("UserPromptSubmit", context))
405        }
406        "pretool-guard" => {
407            let verdict = evaluate_pre_tool(&input, &config);
408            let target = extract_path(input.tool_input.as_ref())
409                .or_else(|| extract_command(input.tool_input.as_ref()));
410            append_hook_event(
411                &cwd,
412                &input,
413                "pretool-guard",
414                LedgerEventKind::ToolRequested,
415                verdict.authority_level,
416                target,
417                Some(&verdict.decision.to_string()),
418                Some(&verdict.reason),
419            )?;
420            Ok(verdict.to_hook_output())
421        }
422        "posttool-audit" => {
423            let event_kind = classify_posttool_event(&input);
424            let decision = classify_posttool_decision(&input);
425            let target = extract_path(input.tool_input.as_ref())
426                .or_else(|| extract_command(input.tool_input.as_ref()));
427            append_hook_event(
428                &cwd,
429                &input,
430                "posttool-audit",
431                event_kind,
432                AuthorityLevel::L3ExecuteLocal,
433                target,
434                Some(&decision),
435                Some("PostToolUse audited"),
436            )?;
437            Ok(HookOutput::context(
438                "PostToolUse",
439                "CORCEPT audited the completed tool call.",
440            ))
441        }
442        "stop-check" => match evaluate_stop(&cwd, input.stop_hook_active.unwrap_or(false)) {
443            StopVerdict::Allow(reason) => {
444                append_hook_event(
445                    &cwd,
446                    &input,
447                    "stop-check",
448                    LedgerEventKind::StopAllowed,
449                    AuthorityLevel::L0Observe,
450                    None,
451                    Some("allow"),
452                    Some(&reason),
453                )?;
454                Ok(HookOutput::default())
455            }
456            StopVerdict::Block(reason) => {
457                append_hook_event(
458                    &cwd,
459                    &input,
460                    "stop-check",
461                    LedgerEventKind::StopBlocked,
462                    AuthorityLevel::L0Observe,
463                    None,
464                    Some("block"),
465                    Some(&reason),
466                )?;
467                Ok(HookOutput::block(reason))
468            }
469        },
470        other => Ok(HookOutput::block(format!(
471            "Unknown CORCEPT hook command: {other}"
472        ))),
473    }
474}
475
476#[allow(clippy::too_many_arguments)]
477fn append_hook_event(
478    root: &Path,
479    input: &HookEnvelope,
480    command: &str,
481    kind: LedgerEventKind,
482    authority_level: AuthorityLevel,
483    target: Option<String>,
484    decision: Option<&str>,
485    reason: Option<&str>,
486) -> Result<()> {
487    let transition = transition_for(command, kind, decision);
488    let mut metadata = BTreeMap::new();
489    metadata.insert(
490        "transition_id".to_string(),
491        serde_json::Value::String(transition.id().to_string()),
492    );
493    if let Some(tool_input) = &input.tool_input {
494        metadata.insert("tool_input".to_string(), sanitize_value(tool_input));
495    }
496    let event = build_ledger_event(
497        input.session_id.clone(),
498        input
499            .agent_type
500            .clone()
501            .unwrap_or_else(|| "corcept-runtime".to_string()),
502        kind,
503        authority_level,
504        input.tool_name.clone(),
505        target,
506        decision.map(ToOwned::to_owned),
507        reason.map(ToOwned::to_owned),
508        metadata,
509    );
510    let correlation = input
511        .session_id
512        .clone()
513        .unwrap_or_else(|| "unknown".to_string());
514    let outcome = decision.unwrap_or("recorded");
515    let record = SinkRecord::new(correlation, kind, outcome);
516    let dispatcher = SinkDispatcher::hook_default(root);
517    dispatcher.emit_all(&record, Some(&event))?;
518    Ok(())
519}
520
521fn sanitize_value(value: &Value) -> Value {
522    match value {
523        Value::Object(map) => {
524            let sanitized = map
525                .iter()
526                .map(|(key, value)| {
527                    let lower = key.to_ascii_lowercase();
528                    if lower.contains("token")
529                        || lower.contains("secret")
530                        || lower.contains("password")
531                        || lower.contains("key")
532                    {
533                        (key.clone(), Value::String("[REDACTED]".to_string()))
534                    } else {
535                        (key.clone(), sanitize_value(value))
536                    }
537                })
538                .collect();
539            Value::Object(sanitized)
540        }
541        Value::Array(values) => Value::Array(values.iter().map(sanitize_value).collect()),
542        other => other.clone(),
543    }
544}
545
546fn classify_prompt_context(prompt: &str) -> String {
547    let lower = prompt.to_ascii_lowercase();
548    if [
549        "deploy",
550        "prod",
551        "production",
552        "secret",
553        "token",
554        "auth",
555        "billing",
556        "migration",
557    ]
558    .iter()
559    .any(|needle| lower.contains(needle))
560    {
561        "CORCEPT: This prompt appears security- or side-effect-sensitive. Apply doctrine, require concrete evidence, and treat production/external actions as L4.".to_string()
562    } else {
563        "CORCEPT: Use bounded diffs, explicit assumptions, and evidence-backed completion."
564            .to_string()
565    }
566}
567
568fn classify_posttool_event(input: &HookEnvelope) -> LedgerEventKind {
569    match input.tool_name.as_deref().unwrap_or_default() {
570        "Edit" | "Write" | "MultiEdit" | "NotebookEdit" => LedgerEventKind::FileModified,
571        "Bash" => {
572            let command = extract_command(input.tool_input.as_ref()).unwrap_or_default();
573            if is_test_command(&command) {
574                LedgerEventKind::TestRun
575            } else {
576                LedgerEventKind::CommandExecuted
577            }
578        }
579        _ => LedgerEventKind::ToolCompleted,
580    }
581}
582
583fn is_test_command(command: &str) -> bool {
584    let normalized = command
585        .split_whitespace()
586        .collect::<Vec<_>>()
587        .join(" ")
588        .to_ascii_lowercase();
589    let test_prefixes = [
590        "cargo test",
591        "cargo nextest",
592        "npm test",
593        "npm run test",
594        "pnpm test",
595        "pnpm run test",
596        "yarn test",
597        "bun test",
598        "pytest",
599        "python -m pytest",
600        "python3 -m pytest",
601        "go test",
602        "mvn test",
603        "gradle test",
604        "./gradlew test",
605    ];
606    test_prefixes
607        .iter()
608        .any(|prefix| normalized == *prefix || normalized.starts_with(&format!("{prefix} ")))
609}
610
611fn classify_posttool_decision(input: &HookEnvelope) -> String {
612    let exit_code = input
613        .tool_response
614        .as_ref()
615        .and_then(|value| value.get("exit_code"))
616        .and_then(|value| value.as_i64());
617    match exit_code {
618        Some(0) => "pass".to_string(),
619        Some(_) => "fail".to_string(),
620        None => "recorded".to_string(),
621    }
622}
623
624fn render_project_settings() -> Result<String> {
625    let value = json!({
626        "hooks": {
627            "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "corcept hook session-start" }] }],
628            "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "corcept hook user-prompt-submit" }] }],
629            "PreToolUse": [{ "matcher": "Bash|Read|Grep|Glob|Edit|Write|MultiEdit|NotebookEdit|WebFetch|WebSearch", "hooks": [{ "type": "command", "command": "corcept hook pretool-guard" }] }],
630            "PostToolUse": [{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", "hooks": [{ "type": "command", "command": "corcept hook posttool-audit" }] }],
631            "Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "corcept hook stop-check" }] }]
632        }
633    });
634    Ok(serde_json::to_string_pretty(&value)?)
635}
636
637fn render_claude_md() -> &'static str {
638    r#"# CORCEPT Project Instructions
639
640You are operating inside an CORCEPT-governed project.
641
642## Authority
643
644Follow this precedence:
645
6461. Direct user instruction for the current task.
6472. Active CORCEPT doctrine.
6483. Accepted CORCEPT memory.
6494. This file.
6505. Skill or agent-local instructions.
651
652Do not promote memory or doctrine without explicit approval.
653
654## Operating rules
655
656- State assumptions before acting when scope is unclear.
657- Prefer bounded diffs.
658- Do not edit files outside approved task scope.
659- Do not claim tests passed unless you ran them or the user provided evidence.
660- Treat secrets as unreadable; identify their presence only.
661- Use CORCEPT skills for structured workflows.
662
663## Required evidence
664
665For completed coding work, report files changed, tests run, test result, known untested risks, and unresolved issues.
666"#
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use corcept_types::LedgerEventKind;
673    use serde_json::{json, Value};
674    use std::fs;
675    use std::path::PathBuf;
676
677    fn repo_root() -> PathBuf {
678        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
679            .join("../..")
680            .canonicalize()
681            .unwrap()
682    }
683
684    fn load_fixture(name: &str, cwd: &Path) -> String {
685        let raw = fs::read_to_string(repo_root().join("tests/fixtures/hooks").join(name))
686            .unwrap_or_else(|_| panic!("fixture {name}"));
687        let mut value: Value = serde_json::from_str(&raw).expect("fixture json");
688        value["cwd"] = Value::String(cwd.to_string_lossy().into_owned());
689        serde_json::to_string(&value).expect("fixture json string")
690    }
691
692    fn init_temp_project() -> tempfile::TempDir {
693        let dir = tempfile::tempdir().unwrap();
694        init_project(InitOptions {
695            path: dir.path().to_path_buf(),
696            dry_run: false,
697            force: false,
698        })
699        .unwrap();
700        dir
701    }
702
703    #[test]
704    fn dry_run_does_not_write() {
705        let dir = tempfile::tempdir().unwrap();
706        let target = dir.path().join("project");
707        let report = init_project(InitOptions {
708            path: target.clone(),
709            dry_run: true,
710            force: false,
711        })
712        .unwrap();
713        assert!(report.created.iter().any(|p| p.ends_with("config.yaml")));
714        assert!(!target.exists());
715    }
716
717    #[test]
718    fn hook_denies_dangerous_command() {
719        let dir = tempfile::tempdir().unwrap();
720        init_project(InitOptions {
721            path: dir.path().to_path_buf(),
722            dry_run: false,
723            force: false,
724        })
725        .unwrap();
726        let input = json!({
727            "session_id":"s",
728            "transcript_path":"/tmp/t.jsonl",
729            "cwd": dir.path(),
730            "hook_event_name":"PreToolUse",
731            "tool_name":"Bash",
732            "tool_input":{"command":"rm -rf /"},
733            "tool_use_id":"t"
734        });
735        let out = handle_hook(&input.to_string(), "pretool-guard").unwrap();
736        let json = serde_json::to_string(&out).unwrap();
737        assert!(json.contains("deny"));
738    }
739
740    #[test]
741    fn hook_fixture_pretool_denies_rm_rf() {
742        run_hook_fixture("pretool-bash-rm-rf.json", "pretool-guard", |out| {
743            assert!(out.contains("deny"));
744        });
745    }
746
747    #[test]
748    fn hook_fixture_pretool_denies_env_read() {
749        run_hook_fixture("pretool-read-env.json", "pretool-guard", |out| {
750            assert!(out.contains("deny"));
751        });
752    }
753
754    #[test]
755    fn hook_fixture_pretool_asks_npm_install() {
756        run_hook_fixture("pretool-bash-npm-install.json", "pretool-guard", |out| {
757            assert!(out.contains("ask"));
758        });
759    }
760
761    #[test]
762    fn hook_fixture_pretool_allows_safe_echo() {
763        run_hook_fixture("pretool-bash-safe.json", "pretool-guard", |out| {
764            assert!(!out.contains("deny") && !out.contains("ask"));
765        });
766    }
767
768    #[test]
769    fn hook_fixture_posttool_records_event() {
770        run_hook_fixture("posttool-npm-test.json", "posttool-audit", |_| {});
771    }
772
773    #[test]
774    fn hook_fixture_session_start_writes_versioned_event() {
775        let dir = init_temp_project();
776        let raw = load_fixture("session-start.json", dir.path());
777        handle_hook(&raw, "session-start").unwrap();
778        let events = read_events(dir.path()).unwrap();
779        assert!(events.iter().any(|e| {
780            LedgerEventKind::SessionStarted.matches_str(&e.event_type)
781                && e.metadata.get("transition_id").and_then(|v| v.as_str())
782                    == Some("T010_session_start")
783        }));
784    }
785
786    #[test]
787    fn hook_fixture_stop_check_allows_clean_project() {
788        run_hook_fixture("stop-check.json", "stop-check", |out| {
789            assert!(!out.contains("block"));
790        });
791    }
792
793    fn run_hook_fixture(name: &str, command: &str, assert_out: impl FnOnce(&str)) {
794        let dir = init_temp_project();
795        let raw = load_fixture(name, dir.path());
796        let out = handle_hook(&raw, command).unwrap();
797        let json = serde_json::to_string(&out).unwrap();
798        assert_out(&json);
799        assert!(!read_events(dir.path()).unwrap().is_empty());
800    }
801}