use serde::Deserialize;
use std::io::Read;
use std::path::PathBuf;
use super::blast_paths::protected_hit;
fn write_protected() -> Vec<String> {
let mut v = vec![
"core/rules".to_string(),
"core/hooks".to_string(),
"core/gates".to_string(),
"gates".to_string(),
".claude-plugin/hooks/hooks.json".to_string(),
".claude-plugin".to_string(),
".git".to_string(),
"memory/L1_atomic".to_string(),
"src/evidence".to_string(),
"src/guard".to_string(),
];
if let Ok(extra) = std::env::var("YANA_SELFMOD_PROTECTED") {
v.extend(extra.split(':').filter(|s| !s.is_empty()).map(String::from));
}
v
}
fn ledger_path() -> PathBuf {
std::env::var("YANA_TAMPER_LEDGER")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("ledger/selfmod-tamper.log"))
}
#[derive(Deserialize, Default)]
struct ToolInput {
path: Option<String>,
file_path: Option<String>,
target_file: Option<String>,
}
#[derive(Deserialize, Default)]
struct HookEvent {
tool_name: Option<String>,
#[serde(default)]
tool_input: ToolInput,
}
fn deny_json(reason: &str, path: &str, tool: &str) -> i32 {
let msg = format!(
"Blocked self-modification: tool '{}' tried to write '{}'. {}",
tool, path, reason
);
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": msg
}
});
println!("{out}");
append_ledger(&msg);
2
}
fn append_ledger(entry: &str) {
use std::fs::OpenOptions;
use std::io::Write;
let path = ledger_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
let _ = writeln!(f, "{ts} {entry}");
}
}
fn extract_path(input: &ToolInput) -> Option<String> {
input
.path
.clone()
.or_else(|| input.file_path.clone())
.or_else(|| input.target_file.clone())
}
fn is_write_tool(name: &str) -> bool {
matches!(
name,
"write_file"
| "Write"
| "create_file"
| "edit_file"
| "Edit"
| "str_replace_based_edit"
| "str_replace_editor"
| "overwrite_file"
| "patch_file"
)
}
pub fn cmd_self_mod() -> i32 {
let mut buf = String::new();
if std::io::stdin().read_to_string(&mut buf).is_err() {
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason":
"self-mod guard: could not read hook payload. Failing closed."
}
});
println!("{out}");
return 2;
}
let event: HookEvent = serde_json::from_str(&buf).unwrap_or_default();
let tool = event.tool_name.as_deref().unwrap_or("");
if !is_write_tool(tool) {
return 0;
}
let target = match extract_path(&event.tool_input) {
Some(p) if !p.is_empty() => p,
_ => return 0, };
let protected = write_protected();
if let Some(hit) = protected_hit(&target, &protected) {
return deny_json(
&format!(
"Path '{hit}' is part of Yana AI's safety surface. \
Direct edits to rules, hooks, gates, guards, or the hook registry \
must go through a human-reviewed PR, not an agent write tool. \
Use `git diff` + PR if this change is intentional."
),
&target,
tool,
);
}
0
}
#[cfg(test)]
mod tests {
use super::*;
fn make_payload(tool: &str, path: &str) -> String {
serde_json::json!({
"tool_name": tool,
"tool_input": { "path": path }
})
.to_string()
}
fn run(payload: &str) -> i32 {
let event: HookEvent = serde_json::from_str(payload).unwrap_or_default();
let tool = event.tool_name.as_deref().unwrap_or("");
if !is_write_tool(tool) {
return 0;
}
let target = match extract_path(&event.tool_input) {
Some(p) if !p.is_empty() => p,
_ => return 0,
};
let protected = write_protected();
if protected_hit(&target, &protected).is_some() {
return 2;
}
0
}
#[test]
fn blocks_rule_edit() {
assert_eq!(run(&make_payload("Write", "core/rules/00-meta.md")), 2);
}
#[test]
fn blocks_gate_edit() {
assert_eq!(run(&make_payload("str_replace_editor", "gates/truth_gate.md")), 2);
}
#[test]
fn blocks_hook_registry_edit() {
assert_eq!(
run(&make_payload("Edit", ".claude-plugin/hooks/hooks.json")),
2
);
}
#[test]
fn blocks_guard_source_edit() {
assert_eq!(
run(&make_payload("write_file", "src/guard/blast_radius.rs")),
2
);
}
#[test]
fn allows_normal_source_file() {
assert_eq!(run(&make_payload("Write", "src/main.rs")), 0);
}
#[test]
fn allows_docs_edit() {
assert_eq!(run(&make_payload("Edit", "docs/README.md")), 0);
}
#[test]
fn read_tool_always_passes() {
assert_eq!(run(&make_payload("Read", "core/rules/00-meta.md")), 0);
}
#[test]
fn blocks_absolute_path_to_rules() {
std::env::set_var("YANA_REPO_ROOT", "/workspaces/Yana-AI");
let result = run(&make_payload(
"Write",
"/workspaces/Yana-AI/core/rules/00-meta.md",
));
std::env::remove_var("YANA_REPO_ROOT");
assert_eq!(result, 2);
}
}