use anyhow::Result;
use pushkin_core::envelope::{CheckResult, Decision, Violation};
use pushkin_core::events::EventLog;
use pushkin_core::manifest::Manifest;
use pushkin_core::pipeline::{check_write, WriteRequest};
use std::io::Read;
use super::{apply_waivers, events_db_path, load_manifest};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
Prose,
Json,
}
impl Output {
fn from_flag(json: bool) -> Self {
if json {
Output::Json
} else {
Output::Prose
}
}
}
pub fn run(staged: bool, json: bool) -> Result<i32> {
let output = Output::from_flag(json);
let manifest = load_manifest()?;
let log = EventLog::open(events_db_path()?)?;
let session = log.begin_session()?;
if staged {
let result = apply_waivers(check_staged(&manifest)?);
log.append(&session, &result)?;
return Ok(emit(&result, output));
}
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw)?;
let request = match parse_payload(&raw) {
Ok(Payload::Write(request)) => request,
Ok(Payload::Stop) => {
let result = sweep_repo(&manifest)?;
log.append(&session, &result)?;
return Ok(emit(&result, output));
}
Err(partial) => {
if let Some(file_path) = partial {
if manifest.is_protected(&file_path) {
let result = check_write(
&manifest,
&WriteRequest {
file_path,
content: String::new(),
},
);
log.append(&session, &result)?;
return Ok(emit(&result, output));
}
}
log.append_failopen(&session, "malformed hook payload")?;
eprintln!(
"pushkin: unrecognized hook payload, failing open (event logged; \
run `pushkin doctor` if this repeats)"
);
return Ok(0);
}
};
let result = check_write(&manifest, &request);
let result = apply_waivers(super::gate_read_only(&manifest, result, &request.file_path));
log.append(&session, &result)?;
Ok(emit(&result, output))
}
fn emit(result: &CheckResult, output: Output) -> i32 {
match output {
Output::Json => println!(
"{}",
serde_json::to_string_pretty(result).unwrap_or_else(|_| {
r#"{"decision":"block","violations":[],"durationMs":0.0}"#.to_owned()
})
),
Output::Prose => {
if !result.violations.is_empty() {
eprintln!("{}", render(result));
}
}
}
if result.violations.is_empty() {
0
} else {
2
}
}
fn check_staged(manifest: &Manifest) -> Result<CheckResult> {
let started = std::time::Instant::now();
let mut violations: Vec<Violation> = Vec::new();
for file_path in super::git::staged_files()? {
if manifest.is_protected(&file_path) || file_path.starts_with("pushkin/") {
eprintln!(
"pushkin: note — protected surface staged ({file_path}); the write \
gate denies agent edits here and this floor does not block commits. \
A human should own this change."
);
}
if manifest.is_read_only(&file_path) && super::committed_in_head(&file_path) {
violations.push(pushkin_core::pipeline::read_only_violation(&file_path));
continue;
}
if manifest.mapping_for(&file_path).is_none() {
continue;
}
let Some(content) = super::git::staged_content(&file_path)? else {
continue;
};
violations.extend(check_write(manifest, &WriteRequest { file_path, content }).violations);
}
Ok(CheckResult {
decision: if violations.is_empty() {
Decision::Allow
} else {
Decision::Block
},
violations,
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
})
}
enum Payload {
Write(WriteRequest),
Stop,
}
fn parse_payload(raw: &str) -> Result<Payload, Option<String>> {
let value: serde_json::Value = serde_json::from_str(raw).map_err(|_| None)?;
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(Payload::Stop);
}
return Err(None);
};
let file_path = tool_input
.get("file_path")
.and_then(serde_json::Value::as_str)
.ok_or(None)?
.to_owned();
let well_formed = value
.get("tool_name")
.and_then(serde_json::Value::as_str)
.is_some();
let content = tool_input
.get("content")
.and_then(serde_json::Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(Payload::Write(WriteRequest {
file_path,
content: content.to_owned(),
})),
_ => Err(Some(file_path)),
}
}
pub fn sweep_repo(manifest: &Manifest) -> Result<CheckResult> {
let started = std::time::Instant::now();
let mut violations: Vec<Violation> = Vec::new();
for file_path in walk_repo(std::path::Path::new("."))? {
if manifest.mapping_for(&file_path).is_none() {
continue;
}
let content = std::fs::read_to_string(&file_path)?;
violations.extend(check_write(manifest, &WriteRequest { file_path, content }).violations);
}
Ok(CheckResult {
decision: if violations.is_empty() {
Decision::Allow
} else {
Decision::Block
},
violations,
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
})
}
const IGNORED_DIRS: &[&str] = &["node_modules", ".git", ".pushkin", ".scout", "target"];
fn walk_repo(root: &std::path::Path) -> Result<Vec<String>> {
let mut files = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
if !IGNORED_DIRS.contains(&name.as_str()) {
pending.push(path);
}
} else if let Ok(relative) = path.strip_prefix(root) {
files.push(relative.to_string_lossy().into_owned());
}
}
}
files.sort();
Ok(files)
}
fn render(result: &CheckResult) -> String {
let mut lines = vec!["pushkin: write blocked. Fix the violations below and retry.".to_owned()];
for violation in &result.violations {
let contract = violation
.contract
.as_deref()
.map(|name| format!(" (contract: {name})"))
.unwrap_or_default();
lines.push(format!(
" {}:{} [{}]{contract}",
violation.file, violation.line, violation.rule
));
lines.push(format!(" fix: {}", violation.fix_hint));
for suggestion in &violation.suggestions {
lines.push(format!(" try: {suggestion}"));
}
}
lines.join("\n")
}