use serde_json::Value;
use trusty_mpm_core::hook::{HookEvent, HookEventRecord};
use trusty_mpm_core::overseer::{OverseerContext, OverseerDecision};
use trusty_mpm_core::session::SessionId;
use crate::audit::AuditEntry;
use crate::state::DaemonState;
const NOISE_PATTERNS: &[&str] = &[
".com.google.chrome",
".com.apple.",
"com.apple.",
"preferences",
"cookies",
"history",
"cache",
"gpucache",
"indexeddb",
"localstorage",
"sessionstorage",
".ds_store",
".spotlight-",
".temporaryitems",
".trashes",
".fseventsd",
".log",
".tmp",
".lock",
"node_modules/",
"/target/",
"/.git/",
];
const CODE_EXTENSIONS: &[&str] = &[
".rs", ".toml", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".java", ".kt", ".swift", ".c",
".cpp", ".h", ".hpp", ".md", ".yaml", ".yml", ".json", ".sh", ".env", ".sql", ".html", ".css",
".scss", ".vue", ".svelte",
];
const SOURCE_DIRS: &[&str] = &["/src/", "/lib/", "/crates/", "/packages/"];
fn is_coding_file(path: &str) -> bool {
let lower = path.to_lowercase();
if NOISE_PATTERNS.iter().any(|p| lower.contains(p)) {
return false;
}
if CODE_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) {
return true;
}
SOURCE_DIRS.iter().any(|dir| path.contains(dir))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookDecision {
Allow,
Block {
reason: String,
},
Respond {
text: String,
},
FlagForHuman {
summary: String,
},
}
impl From<OverseerDecision> for HookDecision {
fn from(d: OverseerDecision) -> Self {
match d {
OverseerDecision::Allow => Self::Allow,
OverseerDecision::Block { reason } => Self::Block { reason },
OverseerDecision::Respond { text } => Self::Respond { text },
OverseerDecision::FlagForHuman { summary } => Self::FlagForHuman { summary },
}
}
}
impl HookDecision {
pub fn tag(&self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Block { .. } => "block",
Self::Respond { .. } => "respond",
Self::FlagForHuman { .. } => "flag",
}
}
pub fn detail(&self) -> Option<&str> {
match self {
Self::Allow => None,
Self::Block { reason } => Some(reason),
Self::Respond { text } => Some(text),
Self::FlagForHuman { summary } => Some(summary),
}
}
}
pub struct HookService<'s> {
state: &'s DaemonState,
}
impl<'s> HookService<'s> {
pub fn new(state: &'s DaemonState) -> Self {
Self { state }
}
pub fn process(
&self,
session: SessionId,
event: HookEvent,
mut payload: Value,
) -> HookDecision {
let overseer = self.state.overseer();
if overseer.is_enabled()
&& let Some(decision) = self.run_overseer(&overseer, event, session, &payload)
{
if let OverseerDecision::Block { reason } = &decision {
return HookDecision::Block {
reason: reason.clone(),
};
}
if let OverseerDecision::Respond { text } = &decision {
tracing::info!("overseer auto-response for {session:?}: {text}");
}
}
if event == HookEvent::PostToolUse {
let tool_name = payload
.get("tool")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let cfg = self.state.optimizer_config();
crate::optimizer::optimize_tool_output(&cfg, &tool_name, &mut payload);
}
if event == HookEvent::FileChanged {
let path = payload.get("path").and_then(Value::as_str).unwrap_or("");
if !is_coding_file(path) {
tracing::trace!("dropped FileChanged noise: {path}");
return HookDecision::Allow;
}
}
self.state
.push_hook_event(HookEventRecord::now(session, event, payload));
HookDecision::Allow
}
fn context(&self, session: SessionId, payload: &Value) -> OverseerContext {
let tmux_name = self
.state
.session(session)
.map(|s| s.tmux_name)
.unwrap_or_else(|| session.0.to_string());
let tool_name = payload
.get("tool")
.and_then(Value::as_str)
.map(str::to_string);
let tool_input = payload
.get("input")
.map(|v| v.to_string())
.or_else(|| Some(payload.to_string()));
OverseerContext::new(session, tmux_name, tool_name, tool_input)
}
fn run_overseer(
&self,
overseer: &std::sync::Arc<dyn trusty_mpm_core::overseer::Overseer>,
event: HookEvent,
session: SessionId,
payload: &Value,
) -> Option<OverseerDecision> {
let ctx = self.context(session, payload);
let (event_label, decision) = match event {
HookEvent::PreToolUse => ("PreToolUse", overseer.pre_tool_use(&ctx)),
HookEvent::PostToolUse => {
let output = payload.get("output").and_then(Value::as_str).unwrap_or("");
("PostToolUse", overseer.post_tool_use(&ctx, output))
}
_ => return None,
};
self.state.audit().log(AuditEntry::from_decision(
&ctx,
event_label,
&decision,
self.state.overseer_handler(),
));
Some(decision)
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm_core::session::{ControlModel, Session, SessionStatus};
#[test]
fn decision_converts_from_overseer() {
assert_eq!(
HookDecision::from(OverseerDecision::Allow),
HookDecision::Allow
);
assert_eq!(
HookDecision::from(OverseerDecision::Block { reason: "x".into() }),
HookDecision::Block { reason: "x".into() }
);
assert_eq!(HookDecision::Allow.tag(), "allow");
assert_eq!(
HookDecision::Block { reason: "r".into() }.detail(),
Some("r")
);
}
#[test]
fn process_records_event_with_disabled_overseer() {
let state = DaemonState::new();
let id = SessionId::new();
let mut s = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Active;
state.register_session(s);
let svc = HookService::new(&state);
let decision = svc.process(
id,
HookEvent::PreToolUse,
serde_json::json!({ "tool": "Bash" }),
);
assert_eq!(decision, HookDecision::Allow);
assert_eq!(state.recent_hook_events().len(), 1);
}
#[test]
fn is_coding_file_rejects_noise() {
assert!(!is_coding_file(
"/var/folders/abc/.com.google.Chrome.xyz/Preferences"
));
assert!(!is_coding_file(
"/Users/masa/Library/Preferences/com.apple.finder.plist"
));
assert!(!is_coding_file("/Users/masa/Projects/.DS_Store"));
assert!(!is_coding_file("/private/var/.Spotlight-V100/something"));
assert!(!is_coding_file("/tmp/daemon.log"));
assert!(!is_coding_file("/tmp/work.tmp"));
assert!(!is_coding_file("/var/run/sshd.lock"));
assert!(!is_coding_file(
"/Users/masa/Projects/app/node_modules/lodash/index.js"
));
assert!(!is_coding_file(
"/Users/masa/Projects/trusty/target/debug/main"
));
assert!(!is_coding_file(
"/Users/masa/Projects/app/.git/objects/pack/pack-abc.idx"
));
}
#[test]
fn is_coding_file_accepts_source() {
assert!(is_coding_file(
"/Users/masa/Projects/trusty/crates/core/src/lib.rs"
));
assert!(is_coding_file("/Users/masa/Projects/trusty/Cargo.toml"));
assert!(is_coding_file(
"/Users/masa/Projects/app/src/components/Button.tsx"
));
assert!(is_coding_file("/Users/masa/Projects/app/scripts/build.py"));
assert!(is_coding_file("/Users/masa/Projects/app/README.md"));
assert!(is_coding_file(
"/Users/masa/Projects/app/.github/workflows/ci.yaml"
));
assert!(is_coding_file("/Users/masa/Projects/app/schema.sql"));
assert!(is_coding_file("/Users/masa/Projects/app/.env"));
}
#[test]
fn is_coding_file_accepts_source_dir() {
assert!(is_coding_file("/Users/masa/Projects/app/src/Makefile"));
assert!(is_coding_file(
"/Users/masa/Projects/trusty/crates/daemon/src/BUILD"
));
assert!(is_coding_file(
"/Users/masa/Projects/app/packages/ui/Dockerfile"
));
assert!(is_coding_file("/Users/masa/Projects/lib/core/something"));
}
#[test]
fn is_coding_file_drops_unknown_extension_outside_source_dirs() {
assert!(!is_coding_file("/Users/masa/Downloads/somefile.xyz"));
assert!(!is_coding_file("/Users/masa/.zsh_history"));
}
#[test]
fn file_changed_noise_is_not_recorded() {
let state = DaemonState::new();
let id = SessionId::new();
let mut s = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Active;
state.register_session(s);
let svc = HookService::new(&state);
let decision = svc.process(
id,
HookEvent::FileChanged,
serde_json::json!({
"path": "/var/folders/abc/.com.google.Chrome.xyz/Preferences"
}),
);
assert_eq!(decision, HookDecision::Allow);
assert_eq!(state.recent_hook_events().len(), 0);
}
#[test]
fn file_changed_source_file_is_recorded() {
let state = DaemonState::new();
let id = SessionId::new();
let mut s = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Active;
state.register_session(s);
let svc = HookService::new(&state);
let decision = svc.process(
id,
HookEvent::FileChanged,
serde_json::json!({
"path": "/Users/masa/Projects/trusty/crates/core/src/lib.rs"
}),
);
assert_eq!(decision, HookDecision::Allow);
assert_eq!(state.recent_hook_events().len(), 1);
}
#[test]
fn non_file_changed_events_bypass_filter() {
let state = DaemonState::new();
let id = SessionId::new();
let mut s = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Active;
state.register_session(s);
let svc = HookService::new(&state);
let decision = svc.process(id, HookEvent::SessionStart, serde_json::json!({}));
assert_eq!(decision, HookDecision::Allow);
assert_eq!(state.recent_hook_events().len(), 1);
}
}