use crate::decision::{Decision, DecisionKind, Severity};
use crate::facts::Facts;
use crate::facts::project::LockKind;
use crate::facts::shell::{Argv, unwrap_sudo};
use crate::hook_input::HookInput;
use crate::reason;
use super::ConfigRule;
const PNPM_RULE_ID: &str = "core.project_hygiene.lock-mismatch-pnpm";
const UV_RULE_ID: &str = "core.project_hygiene.lock-mismatch-uv";
const PROTECTED_GIT_RULE_ID: &str = "core.project_hygiene.protected-branch-destructive-git";
pub struct LockMismatchPnpm;
pub struct LockMismatchUv;
pub struct ProtectedBranchDestructiveGit;
impl ConfigRule for LockMismatchPnpm {
fn id(&self) -> &str {
PNPM_RULE_ID
}
fn severity(&self) -> Severity {
Severity::High
}
fn default_decision(&self) -> DecisionKind {
DecisionKind::Deny
}
fn evaluate(&self, facts: &Facts, _input: &HookInput) -> Option<Decision> {
if !facts.project.lock_files.contains(&LockKind::PnpmLock) {
return None;
}
let bash = facts.bash.as_ref()?;
let triggered = bash.commands().into_iter().any(is_npm_or_yarn_install);
if !triggered {
return None;
}
let reason = reason::build(
PNPM_RULE_ID,
"This repository uses pnpm-lock.yaml. Running npm install or yarn install \
would write a different lock file and create dependency drift.",
&[
"Use pnpm install (or pnpm add <pkg>) instead.",
"Remove pnpm-lock.yaml first if the project is intentionally migrating.",
],
);
Some(Decision::Deny {
rule_id: PNPM_RULE_ID.into(),
reason,
})
}
}
impl ConfigRule for LockMismatchUv {
fn id(&self) -> &str {
UV_RULE_ID
}
fn severity(&self) -> Severity {
Severity::High
}
fn default_decision(&self) -> DecisionKind {
DecisionKind::Deny
}
fn evaluate(&self, facts: &Facts, _input: &HookInput) -> Option<Decision> {
if !facts.project.lock_files.contains(&LockKind::UvLock) {
return None;
}
let bash = facts.bash.as_ref()?;
let triggered = bash.commands().into_iter().any(is_pip_install);
if !triggered {
return None;
}
let reason = reason::build(
UV_RULE_ID,
"This repository uses uv.lock. Running pip install would bypass the uv \
resolver and desynchronise the lock file.",
&[
"Use uv pip install or uv add <pkg> instead.",
"Remove uv.lock first if the project is intentionally migrating away from uv.",
],
);
Some(Decision::Deny {
rule_id: UV_RULE_ID.into(),
reason,
})
}
}
impl ConfigRule for ProtectedBranchDestructiveGit {
fn id(&self) -> &str {
PROTECTED_GIT_RULE_ID
}
fn severity(&self) -> Severity {
Severity::High
}
fn default_decision(&self) -> DecisionKind {
DecisionKind::Deny
}
fn evaluate(&self, facts: &Facts, _input: &HookInput) -> Option<Decision> {
if !facts.project.on_protected_branch {
return None;
}
let bash = facts.bash.as_ref()?;
let triggered = bash.commands().into_iter().any(invokes_destructive_git);
if !triggered {
return None;
}
let branch = facts.project.current_branch.as_deref().unwrap_or("");
let problem = format!(
"Destructive git operations are not allowed on protected branch {branch:?}. \
core.git would normally ask, but project_hygiene escalates to deny on \
protected branches to prevent accidental history rewrites."
);
let reason = reason::build(
PROTECTED_GIT_RULE_ID,
&problem,
&[
"Switch to a feature branch (git switch -c <branch>) before running this.",
"Open a pull request rather than rewriting protected branch history directly.",
"If the operation is genuinely required, allowlist the rule with an expiry.",
],
);
Some(Decision::Deny {
rule_id: PROTECTED_GIT_RULE_ID.into(),
reason,
})
}
}
const NPM_HEADS: &[&str] = &["npm", "/usr/bin/npm", "/usr/local/bin/npm"];
const YARN_HEADS: &[&str] = &["yarn", "/usr/bin/yarn", "/usr/local/bin/yarn"];
const PIP_HEADS: &[&str] = &[
"pip",
"pip3",
"/usr/bin/pip",
"/usr/local/bin/pip",
"/usr/bin/pip3",
"/usr/local/bin/pip3",
];
const GIT_HEADS: &[&str] = &["git", "/usr/bin/git", "/usr/local/bin/git"];
fn is_npm_or_yarn_install(argv: &Argv) -> bool {
if let Some(unwrapped) = unwrap_sudo(argv) {
return is_npm_or_yarn_install(&unwrapped);
}
let head = argv.head.as_str();
if NPM_HEADS.contains(&head) {
return is_install_subcommand(argv, &["install", "i", "ci", "add"]);
}
if YARN_HEADS.contains(&head) {
return matches!(
first_positional(argv),
None | Some("install" | "add" | "ci")
);
}
false
}
fn is_pip_install(argv: &Argv) -> bool {
if let Some(unwrapped) = unwrap_sudo(argv) {
return is_pip_install(&unwrapped);
}
if !PIP_HEADS.contains(&argv.head.as_str()) {
return false;
}
is_install_subcommand(argv, &["install"])
}
fn invokes_destructive_git(argv: &Argv) -> bool {
if let Some(unwrapped) = unwrap_sudo(argv) {
return invokes_destructive_git(&unwrapped);
}
if !GIT_HEADS.contains(&argv.head.as_str()) {
return false;
}
let sub = match first_positional(argv) {
Some(s) => s,
None => return false,
};
let rest: Vec<&str> = argv
.args
.iter()
.map(String::as_str)
.skip_while(|a| *a != sub)
.skip(1)
.collect();
match sub {
"reset" => rest.contains(&"--hard"),
"clean" => has_clean_fdx(&rest),
"branch" => rest.iter().any(|a| {
*a == "-D"
|| (a.starts_with('-') && !a.starts_with("--") && a.contains('D'))
|| *a == "--delete=force"
}),
"stash" => rest.contains(&"clear"),
_ => false,
}
}
fn has_clean_fdx(rest: &[&str]) -> bool {
let mut has_force = false;
let mut has_dir = false;
let mut has_ignored = false;
let mut has_dry_run = false;
for arg in rest {
if *arg == "--force" {
has_force = true;
continue;
}
if arg.starts_with("--") {
continue;
}
let Some(body) = arg.strip_prefix('-') else {
continue;
};
for flag in body.chars() {
if flag == 'e' {
break;
}
has_force |= flag == 'f';
has_dir |= flag == 'd';
has_ignored |= flag == 'x' || flag == 'X';
has_dry_run |= flag == 'n';
}
}
has_force && has_dir && has_ignored && !has_dry_run
}
fn is_install_subcommand(argv: &Argv, accepted: &[&str]) -> bool {
match first_positional(argv) {
Some(sub) => accepted.contains(&sub),
None => false,
}
}
fn first_positional(argv: &Argv) -> Option<&str> {
argv.args
.iter()
.find(|a| !a.starts_with('-'))
.map(String::as_str)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::facts::project::ProjectFacts;
use crate::hook_input::HookInput;
fn bash(cmd: &str) -> HookInput {
HookInput {
tool_name: "Bash".into(),
tool_input: serde_json::json!({ "command": cmd }),
}
}
fn facts_with_project(input: &HookInput, project: ProjectFacts) -> Facts {
let mut f = crate::facts::extract(input);
f.project = project;
f
}
fn pnpm_project() -> ProjectFacts {
ProjectFacts {
lock_files: vec![LockKind::PnpmLock],
..Default::default()
}
}
fn uv_project() -> ProjectFacts {
ProjectFacts {
lock_files: vec![LockKind::UvLock],
..Default::default()
}
}
fn protected_branch() -> ProjectFacts {
ProjectFacts {
current_branch: Some("main".into()),
on_protected_branch: true,
..Default::default()
}
}
#[test]
fn pnpm_denies_npm_install_when_pnpm_lock_present() {
let input = bash("npm install lodash");
let facts = facts_with_project(&input, pnpm_project());
let result = LockMismatchPnpm.evaluate(&facts, &input);
assert!(matches!(result, Some(Decision::Deny { .. })));
}
#[test]
fn pnpm_denies_yarn_install_when_pnpm_lock_present() {
for cmd in ["yarn install", "yarn", "yarn add lodash"] {
let input = bash(cmd);
let facts = facts_with_project(&input, pnpm_project());
let result = LockMismatchPnpm.evaluate(&facts, &input);
assert!(
matches!(result, Some(Decision::Deny { .. })),
"expected deny for {cmd:?}"
);
}
}
#[test]
fn pnpm_denies_npm_ci_when_pnpm_lock_present() {
let input = bash("npm ci");
let facts = facts_with_project(&input, pnpm_project());
let result = LockMismatchPnpm.evaluate(&facts, &input);
assert!(matches!(result, Some(Decision::Deny { .. })));
}
#[test]
fn pnpm_allows_npm_install_when_no_pnpm_lock() {
let input = bash("npm install");
let facts = facts_with_project(&input, ProjectFacts::default());
assert!(LockMismatchPnpm.evaluate(&facts, &input).is_none());
}
#[test]
fn pnpm_allows_pnpm_install_even_with_pnpm_lock() {
let input = bash("pnpm install");
let facts = facts_with_project(&input, pnpm_project());
assert!(LockMismatchPnpm.evaluate(&facts, &input).is_none());
}
#[test]
fn pnpm_allows_npm_run_command() {
let input = bash("npm run build");
let facts = facts_with_project(&input, pnpm_project());
assert!(LockMismatchPnpm.evaluate(&facts, &input).is_none());
}
#[test]
fn pnpm_denies_via_sudo_wrapper() {
let input = bash("sudo npm install");
let facts = facts_with_project(&input, pnpm_project());
assert!(matches!(
LockMismatchPnpm.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn uv_denies_pip_install_when_uv_lock_present() {
let input = bash("pip install requests");
let facts = facts_with_project(&input, uv_project());
let result = LockMismatchUv.evaluate(&facts, &input);
assert!(matches!(result, Some(Decision::Deny { .. })));
}
#[test]
fn uv_denies_pip3_install_when_uv_lock_present() {
let input = bash("pip3 install requests");
let facts = facts_with_project(&input, uv_project());
assert!(matches!(
LockMismatchUv.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn uv_allows_pip_install_when_no_uv_lock() {
let input = bash("pip install requests");
let facts = facts_with_project(&input, ProjectFacts::default());
assert!(LockMismatchUv.evaluate(&facts, &input).is_none());
}
#[test]
fn uv_allows_uv_pip_install_even_with_uv_lock() {
let input = bash("uv pip install requests");
let facts = facts_with_project(&input, uv_project());
assert!(LockMismatchUv.evaluate(&facts, &input).is_none());
}
#[test]
fn uv_allows_pip_uninstall() {
let input = bash("pip uninstall requests");
let facts = facts_with_project(&input, uv_project());
assert!(LockMismatchUv.evaluate(&facts, &input).is_none());
}
#[test]
fn protected_git_denies_reset_hard_on_protected_branch() {
let input = bash("git reset --hard HEAD~1");
let facts = facts_with_project(&input, protected_branch());
let result = ProtectedBranchDestructiveGit.evaluate(&facts, &input);
assert!(
matches!(result, Some(Decision::Deny { rule_id, .. }) if rule_id == PROTECTED_GIT_RULE_ID)
);
}
#[test]
fn protected_git_denies_reset_hard_via_sudo_user_option() {
let input = bash("sudo -u root git reset --hard HEAD~1");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_denies_clean_fdx_on_protected_branch() {
let input = bash("git clean -fdx");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_denies_split_clean_fdx_on_protected_branch() {
let input = bash("git clean -f -d -x");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_denies_clean_fdx_with_exclude_pattern_containing_n() {
let input = bash("git clean -fdx -enode_modules");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_denies_branch_force_delete_on_protected_branch() {
let input = bash("git branch -D feature");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_denies_stash_clear_on_protected_branch() {
let input = bash("git stash clear");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_allows_when_branch_is_not_protected() {
let input = bash("git reset --hard HEAD~1");
let facts = facts_with_project(&input, ProjectFacts::default());
assert!(
ProtectedBranchDestructiveGit
.evaluate(&facts, &input)
.is_none()
);
}
#[test]
fn protected_git_allows_safe_git_commands_on_protected_branch() {
for cmd in [
"git status",
"git log",
"git push origin main",
"git reset --soft HEAD~1",
"git branch -d feature",
] {
let input = bash(cmd);
let facts = facts_with_project(&input, protected_branch());
assert!(
ProtectedBranchDestructiveGit
.evaluate(&facts, &input)
.is_none(),
"expected allow for {cmd:?}"
);
}
}
#[test]
fn protected_git_denies_via_sudo() {
let input = bash("sudo git reset --hard HEAD");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. })
));
}
#[test]
fn protected_git_ignores_non_bash_tools() {
let input = HookInput {
tool_name: "Read".into(),
tool_input: serde_json::json!({ "command": "git reset --hard" }),
};
let facts = facts_with_project(&input, protected_branch());
assert!(
ProtectedBranchDestructiveGit
.evaluate(&facts, &input)
.is_none()
);
}
#[test]
fn rules_default_to_high_severity_deny_overridable() {
for rule in [
&LockMismatchPnpm as &dyn ConfigRule,
&LockMismatchUv as &dyn ConfigRule,
&ProtectedBranchDestructiveGit as &dyn ConfigRule,
] {
assert_eq!(rule.severity(), Severity::High);
assert_eq!(rule.default_decision(), DecisionKind::Deny);
assert!(rule.overridable());
assert!(!rule.hard_deny());
}
}
#[test]
fn uv_denies_pip_install_via_sudo_wrapper() {
let input = bash("sudo pip install requests");
let facts = facts_with_project(&input, uv_project());
let result = LockMismatchUv.evaluate(&facts, &input);
assert!(matches!(result, Some(Decision::Deny { .. })));
}
#[test]
fn protected_branch_rule_does_not_fire_for_non_git_command() {
let input = bash("rm somefile");
let facts = facts_with_project(&input, protected_branch());
assert!(
ProtectedBranchDestructiveGit
.evaluate(&facts, &input)
.is_none()
);
}
#[test]
fn protected_branch_rule_does_not_fire_for_git_with_no_subcommand() {
let input = bash("git");
let facts = facts_with_project(&input, protected_branch());
assert!(
ProtectedBranchDestructiveGit
.evaluate(&facts, &input)
.is_none()
);
}
#[test]
fn protected_branch_rule_fires_for_git_clean_with_long_force_flag() {
let input = bash("git clean --force -dx");
let facts = facts_with_project(&input, protected_branch());
assert!(matches!(
ProtectedBranchDestructiveGit.evaluate(&facts, &input),
Some(Decision::Deny { .. }),
));
}
#[test]
fn pnpm_does_not_fire_for_npm_with_no_subcommand() {
let input = bash("npm");
let facts = facts_with_project(&input, pnpm_project());
assert!(LockMismatchPnpm.evaluate(&facts, &input).is_none());
}
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_protected_rule_silent_off_protected_branch(
cmd in proptest::string::string_regex("git [a-z ]{0,40}").expect("regex"),
) {
let input = bash(&cmd);
let facts = facts_with_project(&input, ProjectFacts::default());
prop_assert!(
ProtectedBranchDestructiveGit
.evaluate(&facts, &input)
.is_none()
);
}
#[test]
fn pbt_lock_rules_silent_when_no_lock(
cmd in proptest::string::string_regex("(npm|yarn|pip|pip3) [a-z ]{0,40}").expect("regex"),
) {
let input = bash(&cmd);
let facts = facts_with_project(&input, ProjectFacts::default());
prop_assert!(LockMismatchPnpm.evaluate(&facts, &input).is_none());
prop_assert!(LockMismatchUv.evaluate(&facts, &input).is_none());
}
}
}