pub mod affected;
pub mod authoring;
pub mod board;
pub mod check;
pub mod compile;
pub mod daemon;
pub mod db;
pub mod doctor;
pub mod external;
pub mod features;
pub mod floor;
pub mod git;
pub mod hook;
pub mod init;
pub mod instructions;
pub mod policy;
pub mod report;
pub mod stats;
pub mod waive;
use anyhow::{Context, Result};
use pushkin_core::envelope::CheckResult;
use pushkin_core::manifest::Manifest;
use std::path::{Path, PathBuf};
pub const MANIFEST_FILE: &str = "pushkin.toml";
pub const EVENTS_DB: &str = ".pushkin/events.db";
pub const CONSENT_FILE: &str = ".pushkin/consent.json";
pub const CLAUDE_SETTINGS: &str = ".claude/settings.json";
pub const CONSENT_VERSION: u32 = 2;
pub const PUSHKIN_MARKER: &str = "pushkin-v1";
pub const MANIFEST_ENV: &str = "PUSHKIN_MANIFEST";
pub enum ManifestSource {
Override { raw: String },
RepoRoot,
WorkingDirectory { git_unavailable: Option<String> },
}
pub struct Resolved {
pub path: PathBuf,
pub source: ManifestSource,
pub root: Option<PathBuf>,
}
pub fn resolve_manifest() -> Result<Resolved> {
if let Ok(raw) = std::env::var(MANIFEST_ENV) {
let path = PathBuf::from(&raw);
if !path.is_file() {
anyhow::bail!(
"{MANIFEST_ENV} is set to {raw}, which is not a readable file. \
Resolution stops here rather than falling back to the repository's \
manifest — an explicit override that silently pointed somewhere else \
would gate you against rules you did not choose. Fix the path or unset \
{MANIFEST_ENV}."
);
}
return Ok(Resolved {
path,
source: ManifestSource::Override { raw },
root: None,
});
}
match git::repo_root() {
Ok(Some(root)) => Ok(Resolved {
path: root.join(MANIFEST_FILE),
source: ManifestSource::RepoRoot,
root: Some(root),
}),
Ok(None) => Ok(Resolved {
path: PathBuf::from(MANIFEST_FILE),
source: ManifestSource::WorkingDirectory {
git_unavailable: None,
},
root: None,
}),
Err(error) => Ok(Resolved {
path: PathBuf::from(MANIFEST_FILE),
source: ManifestSource::WorkingDirectory {
git_unavailable: Some(error.to_string()),
},
root: None,
}),
}
}
pub fn manifest_path() -> Result<PathBuf> {
resolve_manifest().map(|resolved| resolved.path)
}
#[must_use]
pub fn manifest_agent_display() -> String {
let Ok(resolved) = resolve_manifest() else {
return MANIFEST_FILE.to_owned();
};
match resolved.source {
ManifestSource::Override { raw } => raw,
ManifestSource::RepoRoot => resolved
.root
.and_then(|root| {
resolved
.path
.strip_prefix(&root)
.ok()
.map(|rel| rel.display().to_string())
})
.unwrap_or_else(|| MANIFEST_FILE.to_owned()),
ManifestSource::WorkingDirectory { .. } => resolved.path.display().to_string(),
}
}
#[must_use]
pub fn manifest_display() -> String {
manifest_path().map_or_else(
|_| MANIFEST_FILE.to_owned(),
|path| path.canonicalize().unwrap_or(path).display().to_string(),
)
}
pub fn load_manifest() -> Result<Manifest> {
let resolved = resolve_manifest()?;
if let ManifestSource::WorkingDirectory {
git_unavailable: Some(reason),
} = &resolved.source
{
eprintln!(
"pushkin: {reason} Falling back to {} in the working directory; \
resolution is not pinned to a repository root.",
resolved.path.display()
);
}
let path = resolved.path;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read {}", path.display()))?;
Manifest::parse(&text).with_context(|| format!("manifest rejected: {}", path.display()))
}
pub enum GateManifest {
Loaded(Box<Manifest>),
Deny {
error: String,
},
Unpinned {
error: anyhow::Error,
},
}
pub fn load_manifest_for_gate() -> GateManifest {
let resolved = match resolve_manifest() {
Ok(resolved) => resolved,
Err(error) => {
return GateManifest::Deny {
error: format!("{error:#}"),
}
}
};
if let ManifestSource::WorkingDirectory {
git_unavailable: Some(reason),
} = &resolved.source
{
eprintln!(
"pushkin: {reason} Falling back to {} in the working directory; \
resolution is not pinned to a repository root.",
resolved.path.display()
);
}
let outcome = std::fs::read_to_string(&resolved.path)
.with_context(|| format!("cannot read {}", resolved.path.display()))
.and_then(|text| {
Manifest::parse(&text)
.with_context(|| format!("manifest rejected: {}", resolved.path.display()))
});
match outcome {
Ok(manifest) => GateManifest::Loaded(Box::new(manifest)),
Err(error) => match resolved.source {
ManifestSource::Override { .. } | ManifestSource::RepoRoot => GateManifest::Deny {
error: format!("{error:#}"),
},
ManifestSource::WorkingDirectory { .. } => {
eprintln!(
"pushkin: not denying — the manifest was resolved from the working \
directory, where a load failure can still mean a wrong directory; \
the F71 deny is scoped to pinned resolution."
);
GateManifest::Unpinned { error }
}
},
}
}
pub fn events_db_path() -> Result<PathBuf> {
let path = PathBuf::from(EVENTS_DB);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("cannot create .pushkin/")?;
}
Ok(path)
}
#[must_use]
pub fn gate_read_only(manifest: &Manifest, mut result: CheckResult, path: &str) -> CheckResult {
if manifest.is_read_only(path) && committed_in_head(path) {
result
.violations
.push(pushkin_core::pipeline::read_only_violation(path));
result.decision = pushkin_core::envelope::Decision::Block;
}
result
}
#[must_use]
pub fn gate_nested_manifest(mut result: CheckResult, path: &str) -> CheckResult {
if !path_is_manifest_named(path) {
return result;
}
if is_governing_manifest(path) {
return result;
}
result
.violations
.push(pushkin_core::pipeline::nested_manifest_violation(path));
result.decision = pushkin_core::envelope::Decision::Block;
result
}
fn path_is_manifest_named(path: &str) -> bool {
std::path::Path::new(path)
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new(MANIFEST_FILE))
}
fn is_governing_manifest(path: &str) -> bool {
let Ok(resolved) = resolve_manifest() else {
return false;
};
let candidate = match resolved.root.as_ref() {
Some(root) => root.join(path),
None => PathBuf::from(path),
};
normalize_existing(&candidate) == normalize_existing(&resolved.path)
}
fn normalize_existing(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
};
let mut ancestor = absolute.as_path();
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
loop {
if let Ok(canonical) = ancestor.canonicalize() {
let mut out = canonical;
for component in tail.iter().rev() {
out.push(component);
}
return out;
}
match (ancestor.file_name(), ancestor.parent()) {
(Some(name), Some(parent)) => {
tail.push(name);
ancestor = parent;
}
_ => return absolute,
}
}
}
#[must_use]
pub fn gate_raw_read(manifest: &Manifest, mut result: CheckResult, path: &str) -> CheckResult {
if manifest.is_retrieval_gated(path) {
result
.violations
.push(pushkin_core::pipeline::raw_read_violation(
path,
manifest.retrieval_tool(),
));
result.decision = pushkin_core::envelope::Decision::Block;
}
result
}
#[must_use]
pub fn gate_shell_read(manifest: &Manifest, mut result: CheckResult, command: &str) -> CheckResult {
let scope = shell_read_scope(command);
if scope == Scope::Skip {
return result;
}
let already_denied = |r: &CheckResult| {
r.violations
.iter()
.any(|v| v.rule == pushkin_core::pipeline::RULE_RAW_READ)
};
let root = std::env::current_dir().ok();
let mut destination_follows = false;
for token in command.split_whitespace() {
let quoted = token.trim_matches(|c| c == '\'' || c == '"' || c == '`');
if destination_follows {
destination_follows = false;
continue;
}
if quoted.starts_with("<<") {
break;
}
if let Some(attached) = redirect_destination(quoted) {
destination_follows = attached.is_empty();
continue;
}
let candidate = normalize_token(quoted, root.as_deref());
let gated = manifest.is_retrieval_gated(candidate)
|| (scope == Scope::FilesAndDirs && is_gated_directory(manifest, candidate));
if candidate.is_empty() || !gated {
continue;
}
if already_denied(&result) {
break;
}
result
.violations
.push(pushkin_core::pipeline::raw_read_violation(
candidate,
manifest.retrieval_tool(),
));
result.decision = pushkin_core::envelope::Decision::Block;
}
result
}
#[must_use]
pub fn gate_mutation(
manifest: &Manifest,
file: &crate::agents::FileWrite,
tool: &str,
) -> CheckResult {
let started = std::time::Instant::now();
let mut violations = pushkin_core::pipeline::check_mutation_path_rules(manifest, &file.path);
violations.extend(gate_read_only(manifest, empty_result(), &file.path).violations);
violations.extend(gate_nested_manifest(empty_result(), &file.path).violations);
if !violations.is_empty() {
return finish(violations, started);
}
if file.edits.is_empty() && !file.content.is_empty() {
let request = pushkin_core::pipeline::WriteRequest {
file_path: file.path.clone(),
content: file.content.clone(),
};
return finish(
pushkin_core::pipeline::check_write(manifest, &request).violations,
started,
);
}
let on_disk = std::fs::read_to_string(&file.path).ok();
match pushkin_core::pipeline::synthesize(on_disk.as_deref(), &file.edits) {
pushkin_core::pipeline::Synthesis::Content(content) => {
let request = pushkin_core::pipeline::WriteRequest {
file_path: file.path.clone(),
content,
};
finish(
pushkin_core::pipeline::check_write(manifest, &request).violations,
started,
)
}
pushkin_core::pipeline::Synthesis::Refused(why) => {
let mut result =
pushkin_core::pipeline::check_mutation_without_content(manifest, &file.path, tool);
annotate_refusal(&mut result, &why);
result
}
}
}
#[must_use]
pub fn gate_delete(manifest: &Manifest, path: &str) -> CheckResult {
let started = std::time::Instant::now();
finish(unwaivable_path_violations(manifest, path), started)
}
fn unwaivable_path_violations(
manifest: &Manifest,
path: &str,
) -> Vec<pushkin_core::envelope::Violation> {
let mut violations = pushkin_core::pipeline::check_mutation_path_rules(manifest, path);
violations.extend(gate_read_only(manifest, empty_result(), path).violations);
violations
}
#[must_use]
pub fn gate_unreadable_payload(manifest: &Manifest, raw: &str) -> CheckResult {
let started = std::time::Instant::now();
let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
return empty_result();
};
let root = std::env::current_dir().ok();
let mut strings = Vec::new();
collect_strings(&value, &mut strings);
let mut seen = std::collections::BTreeSet::new();
let mut violations = Vec::new();
for text in &strings {
for token in std::iter::once(text.as_str()).chain(text.split_whitespace()) {
let quoted = token.trim_matches(|c| c == '\'' || c == '"' || c == '`');
let candidate = normalize_token(quoted, root.as_deref());
if candidate.is_empty() || !seen.insert(candidate.to_owned()) {
continue;
}
violations.extend(unwaivable_path_violations(manifest, candidate));
}
}
finish(violations, started)
}
fn collect_strings(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::String(text) => out.push(text.clone()),
serde_json::Value::Array(items) => {
for item in items {
collect_strings(item, out);
}
}
serde_json::Value::Object(map) => {
for nested in map.values() {
collect_strings(nested, out);
}
}
_ => {}
}
}
fn empty_result() -> CheckResult {
CheckResult {
decision: pushkin_core::envelope::Decision::Allow,
violations: Vec::new(),
duration_ms: 0.0,
}
}
fn finish(
violations: Vec<pushkin_core::envelope::Violation>,
started: std::time::Instant,
) -> CheckResult {
CheckResult {
decision: if violations.is_empty() {
pushkin_core::envelope::Decision::Allow
} else {
pushkin_core::envelope::Decision::Block
},
violations,
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
}
}
fn annotate_refusal(result: &mut CheckResult, why: &str) {
for violation in &mut result.violations {
if violation.rule == pushkin_core::pipeline::RULE_CONTENT_UNAVAILABLE {
violation.fix_hint = format!("{} Reconstruction failed: {why}.", violation.fix_hint);
}
}
}
fn redirect_destination(token: &str) -> Option<&str> {
let after_descriptor = token.trim_start_matches(|c: char| c.is_ascii_digit() || c == '&');
let rest = after_descriptor
.strip_prefix(">>")
.or_else(|| after_descriptor.strip_prefix('>'))?;
Some(rest.strip_prefix('|').unwrap_or(rest))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Skip,
Files,
FilesAndDirs,
}
const CONTENT_READERS: &[&str] = &[
"cat", "less", "more", "bat", "nl", "od", "xxd", "strings", "tac", "rev", "awk", "head",
"tail", "sed",
];
const SEARCHERS: &[&str] = &["grep", "egrep", "fgrep", "rg", "ag", "ack", "ugrep"];
const RECURSIVE_BY_DEFAULT: &[&str] = &["rg", "ag", "ack", "ugrep"];
fn shell_read_scope(command: &str) -> Scope {
let Some(head) = command.split_whitespace().next() else {
return Scope::Skip;
};
let verb = head.rsplit('/').next().unwrap_or(head);
if verb == "git" {
return git_scope(command);
}
if SEARCHERS.contains(&verb) {
return if RECURSIVE_BY_DEFAULT.contains(&verb) || has_recursive_flag(command) {
Scope::FilesAndDirs
} else {
Scope::Files
};
}
if CONTENT_READERS.contains(&verb) {
return Scope::Files;
}
Scope::Skip
}
fn git_scope(command: &str) -> Scope {
let mut tokens = command.split_whitespace().skip(1);
let mut subcommand = None;
while let Some(token) = tokens.next() {
if token == "-C" || token == "-c" {
tokens.next();
} else if !token.starts_with('-') {
subcommand = Some(token);
break;
}
}
let names_only = ["--stat", "--name-only", "--name-status", "--shortstat"]
.iter()
.any(|flag| has_flag(command, flag));
match subcommand {
Some("grep") => Scope::FilesAndDirs,
Some("blame" | "cat-file" | "annotate") => Scope::Files,
Some("show" | "diff" | "diff-tree") if !names_only => Scope::Files,
Some("log")
if ["-p", "-u", "--patch", "-S", "-G"]
.iter()
.any(|f| has_flag(command, f)) =>
{
Scope::Files
}
_ => Scope::Skip,
}
}
fn has_flag(command: &str, flag: &str) -> bool {
command
.split_whitespace()
.any(|token| token == flag || token.split_once('=').is_some_and(|(k, _)| k == flag))
}
fn has_recursive_flag(command: &str) -> bool {
command.split_whitespace().any(|token| {
(token.starts_with('-')
&& !token.starts_with("--")
&& token.chars().skip(1).any(|c| c == 'r' || c == 'R'))
|| token == "--recursive"
|| token == "--dereference-recursive"
})
}
fn is_gated_directory(manifest: &Manifest, token: &str) -> bool {
let token = token.trim_end_matches('/');
if token.is_empty() {
return false;
}
manifest.gates.retrieval_paths.iter().any(|glob| {
let prefix = literal_prefix(glob);
prefix.is_empty() || contains_path(prefix, token) || contains_path(token, prefix)
})
}
fn contains_path(ancestor: &str, descendant: &str) -> bool {
descendant == ancestor
|| (descendant.starts_with(ancestor) && descendant[ancestor.len()..].starts_with('/'))
}
fn literal_prefix(glob: &str) -> &str {
match glob
.split('/')
.position(|component| component.contains(['*', '?', '[', '{']))
{
Some(0) => "",
Some(n) => {
let end: usize = glob.split('/').take(n).map(|c| c.len() + 1).sum();
&glob[..end - 1]
}
None => glob,
}
}
fn normalize_token<'a>(token: &'a str, root: Option<&Path>) -> &'a str {
let mut relative = token;
if let Some((rev, rest)) = relative.split_once(':') {
if !rev.contains('/') && !rest.is_empty() && !rest.starts_with('/') {
relative = rest;
}
}
while let Some(rest) = relative.strip_prefix("./") {
relative = rest;
}
let path = Path::new(relative);
root.and_then(|root| path.strip_prefix(root).ok())
.and_then(Path::to_str)
.unwrap_or(relative)
}
pub use git::last_commit_touching;
pub(crate) use git::committed_in_head;
#[must_use]
pub fn apply_waivers(result: pushkin_core::envelope::CheckResult) -> CheckResult {
match pushkin_core::waivers::WaiverSet::load(std::path::Path::new(waive::WAIVERS_FILE)) {
Ok(set) => set.apply_now(result),
Err(_) => result,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
AgentWriteTime,
Floor,
}
pub struct DecideRequest<'a> {
pub manifest: &'a Manifest,
pub action: &'a crate::agents::ToolAction,
pub surface: Surface,
}
pub fn decide(req: &DecideRequest) -> Result<CheckResult> {
if req.action.is_stop {
return decide_stop(req);
}
Ok(match req.action.intent {
crate::agents::Intent::MutateNoContent(tool) => apply_waivers(decide_mutate(req, tool)),
crate::agents::Intent::Delete => apply_waivers(decide_delete(req)),
crate::agents::Intent::Write => decide_write(req),
crate::agents::Intent::ReadWhole
| crate::agents::Intent::ReadRange
| crate::agents::Intent::Shell => apply_waivers(decide_read(req)),
})
}
fn decide_stop(req: &DecideRequest) -> Result<CheckResult> {
let mut result = check::sweep_repo(req.manifest)?;
let floor = floor::on_stop_violations(req.manifest);
if !floor.is_empty() {
result.decision = pushkin_core::envelope::Decision::Block;
result.violations.extend(floor);
}
Ok(apply_waivers(result))
}
fn decide_write(req: &DecideRequest) -> CheckResult {
let started = std::time::Instant::now();
let mut violations = Vec::new();
for file in &req.action.files {
let request = pushkin_core::pipeline::WriteRequest {
file_path: file.path.clone(),
content: file.content.clone(),
};
let per_file = match req.surface {
Surface::AgentWriteTime => daemon::check_or_cold(req.manifest, &request),
Surface::Floor => gate_nested_manifest(
gate_read_only(
req.manifest,
pushkin_core::pipeline::check_write(req.manifest, &request),
&file.path,
),
&file.path,
),
};
violations.extend(per_file.violations);
}
apply_waivers(finish(violations, started))
}
fn decide_mutate(req: &DecideRequest, tool: &str) -> CheckResult {
let started = std::time::Instant::now();
let mut violations = Vec::new();
for file in &req.action.files {
violations.extend(gate_mutation(req.manifest, file, tool).violations);
}
finish(violations, started)
}
fn decide_delete(req: &DecideRequest) -> CheckResult {
let started = std::time::Instant::now();
let mut violations = Vec::new();
for file in &req.action.files {
violations.extend(gate_delete(req.manifest, &file.path).violations);
}
finish(violations, started)
}
fn decide_read(req: &DecideRequest) -> CheckResult {
let started = std::time::Instant::now();
let mut result = empty_result();
if req.action.intent == crate::agents::Intent::ReadWhole {
for file in &req.action.files {
result = gate_raw_read(req.manifest, result, &file.path);
}
}
if let (crate::agents::Intent::Shell, Some(command)) =
(req.action.intent, req.action.command.as_deref())
{
result = gate_shell_read(req.manifest, result, command);
}
result.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
result
}