use crate::app::{App, discover_store};
use crate::cli::HookEvent;
use cyberbrain_core::{Error, Slash};
use cyberbrain_policy::Actor;
use serde_json::json;
use std::io::Write;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::time::Instant;
pub mod events;
pub mod paths;
pub mod payload;
pub mod resident;
pub mod session;
#[cfg(test)]
mod tests;
pub const HOT_PATH_BUDGET_MS: u128 = 15;
pub const SESSION_START_BUDGET_MS: u128 = 150;
pub const DISABLE_ENV: &str = "CYBERBRAIN_DISABLED";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HookOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl HookOutput {
pub fn empty() -> Self {
Self::default()
}
pub fn note(&mut self, line: impl AsRef<str>) {
self.stderr.push_str("cyberbrain: ");
self.stderr.push_str(line.as_ref());
self.stderr.push('\n');
}
pub fn emit(&self) {
if !self.stdout.is_empty() {
let mut out = std::io::stdout().lock();
let _ = out.write_all(self.stdout.as_bytes());
if !self.stdout.ends_with('\n') {
let _ = out.write_all(b"\n");
}
let _ = out.flush();
}
if !self.stderr.is_empty() {
let mut err = std::io::stderr().lock();
let _ = err.write_all(self.stderr.as_bytes());
let _ = err.flush();
}
}
}
pub fn event_name(event: HookEvent) -> &'static str {
match event {
HookEvent::SessionStart => "session-start",
HookEvent::UserPromptSubmit => "user-prompt-submit",
HookEvent::PreToolUse => "pre-tool-use",
HookEvent::PostToolUse => "post-tool-use",
HookEvent::Stop => "stop",
HookEvent::PreCompact => "pre-compact",
}
}
pub fn harness_event_name(event: HookEvent) -> &'static str {
match event {
HookEvent::SessionStart => "SessionStart",
HookEvent::UserPromptSubmit => "UserPromptSubmit",
HookEvent::PreToolUse => "PreToolUse",
HookEvent::PostToolUse => "PostToolUse",
HookEvent::Stop => "Stop",
HookEvent::PreCompact => "PreCompact",
}
}
pub fn budget_ms(event: HookEvent) -> u128 {
match event {
HookEvent::SessionStart => SESSION_START_BUDGET_MS,
_ => HOT_PATH_BUDGET_MS,
}
}
pub fn disabled_by_env() -> Option<String> {
let v = std::env::var(DISABLE_ENV).ok()?;
let t = v.trim();
if t.is_empty() || ["0", "false", "no", "off"].contains(&t.to_ascii_lowercase().as_str()) {
None
} else {
Some(v)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StandDown {
Disabled(String),
NoStore(String),
Unreadable(String),
}
impl StandDown {
pub fn announce(&self) -> String {
match self {
StandDown::Disabled(v) => format!(
"cyberbrain: standing down for this session: {DISABLE_ENV}={v:?} is set. No \
memory is injected and nothing is recorded. Unset it and start a new session \
to re-enable."
),
StandDown::NoStore(why) => format!(
"cyberbrain: standing down: {why}. No memory is injected and nothing is \
recorded. `cyberbrain init` in the project root creates a store."
),
StandDown::Unreadable(why) => format!(
"cyberbrain: standing down: a store exists but could not be opened: {why}. No \
memory is injected and nothing is recorded. `cyberbrain status` and \
`cyberbrain doctor` say more."
),
}
}
}
fn why_no_app(open_error: Option<&Error>) -> StandDown {
if let Some(e) = open_error {
return match e {
Error::Config(msg) if msg.contains("store found") || msg.contains("is not a") => {
StandDown::NoStore(msg.clone())
}
other => StandDown::Unreadable(other.to_string()),
};
}
let explicit = std::env::var_os("CYBERBRAIN_STORE").map(std::path::PathBuf::from);
match discover_store(explicit.as_deref()) {
Err(e) => StandDown::NoStore(e.to_string()),
Ok(p) => StandDown::Unreadable(format!(
"{} exists but the caller could not open it (the reason was not passed to the \
hook; wire `run_with` to see it)",
Slash(&p)
)),
}
}
#[allow(dead_code)]
pub fn run(app: Option<&App>, event: HookEvent, stdin: &str) -> HookOutput {
run_with(app, None, event, stdin)
}
pub fn run_with(
app: Option<&App>,
open_error: Option<&Error>,
event: HookEvent,
stdin: &str,
) -> HookOutput {
let started = Instant::now();
let name = event_name(event);
let outcome = catch_unwind(AssertUnwindSafe(|| {
let stand_down = match (disabled_by_env(), app) {
(Some(v), _) => Some(StandDown::Disabled(v)),
(None, Some(_)) => None,
(None, None) => Some(why_no_app(open_error)),
};
events::dispatch(app, stand_down, event, stdin)
}));
let mut out = match outcome {
Ok(Ok(out)) => out,
Ok(Err(e)) => failed(app, event, stdin, e.to_string()),
Err(panic) => failed(
app,
event,
stdin,
format!("panic: {}", panic_message(&panic)),
),
};
let elapsed = started.elapsed().as_millis();
if elapsed > budget_ms(event) {
out.note(format!(
"hook {name} took {elapsed} ms inside the hook, over its budget of {} ms \
(SPEC §9.1); the harness was not delayed further by this message",
budget_ms(event)
));
}
out.exit_code = 0;
out
}
fn failed(app: Option<&App>, event: HookEvent, stdin: &str, error: String) -> HookOutput {
let name = event_name(event);
let mut out = HookOutput::empty();
let recorded = match app {
Some(app) => {
let session = payload::Payload::parse(stdin).session_id;
app.policy()
.audit()
.record_raw(
&Actor::Hook(name.into()).to_string(),
"hook.error",
format!("hook:{name}"),
json!({ "error": error, "session": session, "stdin_bytes": stdin.len() }),
)
.map(|_| "recorded in the audit log")
.unwrap_or("could not be recorded either")
}
None => "no store to record it in",
};
out.note(format!(
"hook {name} failed internally and stood down: {error} ({recorded}); exit 0, empty \
output, the harness is unaffected (SPEC §9.1)"
));
out
}
fn panic_message(p: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = p.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = p.downcast_ref::<String>() {
s.clone()
} else {
"unprintable panic payload".to_string()
}
}
pub fn install_never_fail_guard() {
std::panic::set_hook(Box::new(|info| {
let mut err = std::io::stderr().lock();
let _ = writeln!(
err,
"cyberbrain: hook panicked: {info}; exiting 0 with empty output so the harness is \
unharmed (SPEC §9.1)"
);
let _ = err.flush();
std::process::exit(0);
}));
}