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
}
}
}
#[derive(Clone, Copy)]
pub struct CheckArgs {
pub staged: bool,
pub json: bool,
}
pub fn run(args: CheckArgs) -> Result<i32> {
let CheckArgs { staged, json } = args;
let output = Output::from_flag(json);
let manifest = load_manifest()?;
if staged && !manifest.git_hooks_enabled() {
eprintln!(
"pushkin: git hooks disabled ([features] git_hooks = false in \
pushkin.toml); staged check skipped"
);
return Ok(0);
}
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 action = match parse_payload(&raw) {
Ok(Payload::Write(request)) => crate::agents::ToolAction {
session: String::new(),
files: vec![crate::agents::FileWrite {
path: request.file_path,
content: request.content,
edits: vec![],
}],
is_stop: false,
intent: crate::agents::Intent::Write,
command: None,
},
Ok(Payload::Stop) => crate::agents::ToolAction {
session: String::new(),
files: vec![],
is_stop: true,
intent: crate::agents::Intent::Write,
command: None,
},
Ok(Payload::MutateNoContent { file, tool }) => crate::agents::ToolAction {
session: String::new(),
files: vec![file],
is_stop: false,
intent: crate::agents::Intent::MutateNoContent(tool),
command: None,
},
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));
}
}
let scanned = apply_waivers(super::gate_unreadable_payload(&manifest, &raw));
if !scanned.violations.is_empty() {
log.append(&session, &scanned)?;
return Ok(emit(&scanned, 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 = super::decide(&super::DecideRequest {
manifest: &manifest,
action: &action,
surface: super::Surface::Floor,
})?;
log.append(&session, &result)?;
Ok(emit(&result, output))
}
fn envelope_json(result: &CheckResult) -> Option<String> {
let mut value = serde_json::to_value(result).ok()?;
value
.as_object_mut()?
.insert("manifest".to_owned(), super::manifest_display().into());
serde_json::to_string_pretty(&value).ok()
}
fn emit(result: &CheckResult, output: Output) -> i32 {
match output {
Output::Json => println!(
"{}",
envelope_json(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 agent_was_denied(file: &str) -> bool {
let Ok(log) = pushkin_core::events::EventLog::open(std::path::Path::new(super::EVENTS_DB))
else {
return false;
};
let since = super::last_commit_touching(file).unwrap_or_default();
log.denied_since(pushkin_core::pipeline::RULE_PROTECTED_PATH, file, &since)
.unwrap_or(false)
}
fn check_staged(manifest: &Manifest) -> Result<CheckResult> {
let started = std::time::Instant::now();
let mut violations: Vec<Violation> = Vec::new();
let mut has_new_read_only = false;
for file_path in super::git::staged_files()? {
if manifest.is_protected(&file_path) || file_path.starts_with("pushkin/") {
if agent_was_denied(&file_path) {
violations.push(pushkin_core::pipeline::protected_path_bypass_violation(
&file_path,
));
continue;
}
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) {
if super::committed_in_head(&file_path) {
violations.push(pushkin_core::pipeline::read_only_violation(&file_path));
continue;
}
has_new_read_only = true;
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);
}
if has_new_read_only {
violations.extend(super::floor::new_read_only_violations(manifest));
}
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,
MutateNoContent {
file: crate::agents::FileWrite,
tool: &'static str,
},
}
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 tool_name = value.get("tool_name").and_then(serde_json::Value::as_str);
let mutation_tool = match tool_name {
Some("Edit") => Some("Edit"),
Some("MultiEdit") => Some("MultiEdit"),
_ => None,
};
if let Some(tool) = mutation_tool {
return Ok(Payload::MutateNoContent {
file: crate::agents::FileWrite {
path: file_path,
content: String::new(),
edits: crate::agents::claude_replacements(tool_input),
},
tool,
});
}
let well_formed = tool_name.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")
}