use std::collections::{HashMap, HashSet};
use std::fs::OpenOptions;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use crate::broker::learnings::{CATEGORY_PERMISSION_PATTERN, LearningRecord};
use crate::broker::messages::BrokerMessage;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManualApproval {
pub timestamp: String,
pub agent_id: String,
pub pattern: String,
pub first_seen: bool,
}
impl ManualApproval {
#[must_use]
pub fn now(agent_id: &str, pattern: &str, first_seen: bool) -> Self {
Self {
timestamp: now_iso8601(),
agent_id: agent_id.to_string(),
pattern: pattern.to_string(),
first_seen,
}
}
}
#[must_use]
pub fn now_iso8601() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[must_use]
pub fn log_path(repo_root: &Path, session: &str) -> PathBuf {
repo_root
.join(".git-paw")
.join("sessions")
.join(format!("{session}.manual-approvals.jsonl"))
}
pub fn append_line(approval: &ManualApproval, log_path: &Path) -> std::io::Result<()> {
if let Some(parent) = log_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(approval)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push('\n');
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
file.write_all(line.as_bytes())
}
pub fn record(approval: &ManualApproval, log_path: &Path) -> bool {
match append_line(approval, log_path) {
Ok(()) => true,
Err(e) => {
eprintln!(
"warning: failed to record manual approval to {}: {e}",
log_path.display()
);
false
}
}
}
#[derive(Debug, Default)]
pub struct SeenPatterns {
seen: HashSet<String>,
}
impl SeenPatterns {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn observe(&mut self, pattern: &str) -> bool {
self.seen.insert(pattern.to_string())
}
#[must_use]
pub fn contains(&self, pattern: &str) -> bool {
self.seen.contains(pattern)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Suggestion {
ProjectAllowlist,
BundledPresetCandidate,
}
impl Suggestion {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::ProjectAllowlist => "project allowlist",
Self::BundledPresetCandidate => "bundled preset candidate",
}
}
#[must_use]
pub fn json_value(self) -> &'static str {
match self {
Self::ProjectAllowlist => "project-allowlist",
Self::BundledPresetCandidate => "bundled-preset",
}
}
}
#[must_use]
pub fn suggest_target(
pattern: &str,
project_name: &str,
branch_name: &str,
worktree_root: Option<&Path>,
) -> Suggestion {
if pattern.split_whitespace().any(|tok| tok.starts_with("./")) {
return Suggestion::ProjectAllowlist;
}
if !project_name.is_empty() && pattern.contains(project_name) {
return Suggestion::ProjectAllowlist;
}
if !branch_name.is_empty() && pattern.contains(branch_name) {
return Suggestion::ProjectAllowlist;
}
if let Some(root) = worktree_root {
let root = root.to_string_lossy();
if !root.is_empty() && pattern.contains(root.as_ref()) {
return Suggestion::ProjectAllowlist;
}
}
Suggestion::BundledPresetCandidate
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AggregatedApproval {
pub pattern: String,
pub count: u64,
pub first_seen: String,
pub last_seen: String,
}
pub fn aggregate(jsonl_path: &Path) -> std::io::Result<Vec<AggregatedApproval>> {
let contents = match std::fs::read_to_string(jsonl_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut order: Vec<String> = Vec::new();
let mut groups: HashMap<String, AggregatedApproval> = HashMap::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<ManualApproval>(line) else {
continue;
};
if let Some(agg) = groups.get_mut(&entry.pattern) {
agg.count += 1;
if entry.timestamp < agg.first_seen {
agg.first_seen.clone_from(&entry.timestamp);
}
if entry.timestamp > agg.last_seen {
agg.last_seen.clone_from(&entry.timestamp);
}
} else {
order.push(entry.pattern.clone());
groups.insert(
entry.pattern.clone(),
AggregatedApproval {
pattern: entry.pattern,
count: 1,
first_seen: entry.timestamp.clone(),
last_seen: entry.timestamp,
},
);
}
}
let mut rows: Vec<AggregatedApproval> = order
.into_iter()
.filter_map(|p| groups.remove(&p))
.collect();
rows.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.pattern.cmp(&b.pattern))
});
Ok(rows)
}
const PROMPT_BOILERPLATE: &[&str] = &[
"requires approval",
"do you want",
"bash command",
"allow this command",
"[y/n]",
"(y/n)",
"press ",
"esc to",
"1. yes",
"2. no",
"❯",
];
fn is_prompt_boilerplate(lower: &str) -> bool {
PROMPT_BOILERPLATE.iter().any(|n| lower.contains(n)) || lower == "yes" || lower == "no"
}
#[must_use]
pub fn extract_forwarded_pattern(captured: &str) -> Option<String> {
if let Some(path) = super::auto_approve::extract_path_from_file_prompt(captured) {
return Some(path);
}
for raw in captured.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if is_prompt_boilerplate(&line.to_ascii_lowercase()) {
continue;
}
let cmd = line
.strip_prefix("Running ")
.or_else(|| line.strip_prefix("$ "))
.unwrap_or(line)
.trim();
if !cmd.is_empty() {
return Some(cmd.to_string());
}
}
None
}
#[derive(Debug)]
pub struct ManualDecisionRecorder {
log_path: PathBuf,
enabled: bool,
learnings_enabled: bool,
project_name: String,
cli: Option<String>,
seen: SeenPatterns,
}
impl ManualDecisionRecorder {
#[must_use]
pub fn new(
log_path: PathBuf,
enabled: bool,
learnings_enabled: bool,
project_name: String,
cli: Option<String>,
) -> Self {
Self {
log_path,
enabled,
learnings_enabled,
project_name,
cli,
seen: SeenPatterns::new(),
}
}
pub fn record_forwarded(&mut self, agent_id: &str, captured: &str) -> Option<BrokerMessage> {
if !self.enabled {
return None;
}
let pattern = extract_forwarded_pattern(captured)?;
let first_seen = self.seen.observe(&pattern);
let approval = ManualApproval::now(agent_id, &pattern, first_seen);
record(&approval, &self.log_path);
if first_seen && self.learnings_enabled {
let suggestion = suggest_target(&pattern, &self.project_name, "", None);
Some(permission_pattern_learning(
agent_id,
&pattern,
1,
suggestion,
self.cli.as_deref(),
SystemTime::now(),
))
} else {
None
}
}
}
#[must_use]
pub fn permission_pattern_learning(
agent_id: &str,
pattern: &str,
count_so_far: u64,
suggested_target: Suggestion,
cli: Option<&str>,
timestamp: SystemTime,
) -> BrokerMessage {
let mut body = serde_json::json!({
"pattern": pattern,
"count_so_far": count_so_far,
"suggested_target": suggested_target.json_value(),
});
if let Some(cli) = cli {
body["cli"] = serde_json::Value::String(cli.to_string());
}
let record = LearningRecord {
category: CATEGORY_PERMISSION_PATTERN.to_string(),
agent_id: agent_id.to_string(),
branch_id: None,
title: format!(
"manual approval `{pattern}` (suggest {})",
suggested_target.label()
),
body,
timestamp,
};
BrokerMessage::from(&record)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn appr(agent: &str, pattern: &str, first_seen: bool, ts: &str) -> ManualApproval {
ManualApproval {
timestamp: ts.to_string(),
agent_id: agent.to_string(),
pattern: pattern.to_string(),
first_seen,
}
}
#[test]
fn log_path_follows_session_template() {
let p = log_path(Path::new("/repo"), "paw-proj");
assert!(p.ends_with(".git-paw/sessions/paw-proj.manual-approvals.jsonl"));
}
#[test]
fn append_creates_file_and_writes_one_line() {
let tmp = TempDir::new().unwrap();
let path = log_path(tmp.path(), "paw-x");
assert!(!path.exists());
append_line(
&appr("feat/a", "make it", true, "2026-05-29T12:00:00Z"),
&path,
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.lines().count(), 1);
let parsed: ManualApproval = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(parsed.pattern, "make it");
assert!(parsed.first_seen);
}
#[test]
fn append_to_existing_file_adds_a_line() {
let tmp = TempDir::new().unwrap();
let path = log_path(tmp.path(), "paw-x");
append_line(
&appr("feat/a", "make it", true, "2026-05-29T12:00:00Z"),
&path,
)
.unwrap();
append_line(
&appr("feat/a", "make it", false, "2026-05-29T12:05:00Z"),
&path,
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content.lines().count(), 2);
}
#[test]
fn record_swallows_errors_when_parent_is_a_file() {
let tmp = TempDir::new().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let path = blocker.join("sessions").join("p.manual-approvals.jsonl");
let ok = record(
&appr("feat/a", "anything", true, "2026-05-29T12:00:00Z"),
&path,
);
assert!(!ok, "write under a file parent must fail gracefully");
}
#[test]
fn record_returns_true_on_success() {
let tmp = TempDir::new().unwrap();
let path = log_path(tmp.path(), "paw-x");
assert!(record(
&appr("feat/a", "ok", true, "2026-05-29T12:00:00Z"),
&path
));
}
#[test]
fn first_seen_toggles_correctly() {
let mut seen = SeenPatterns::new();
assert!(seen.observe("make integration-test"));
assert!(!seen.observe("make integration-test"));
assert!(seen.observe("podman build"));
assert!(seen.contains("make integration-test"));
assert!(!seen.contains("never"));
}
#[test]
fn dot_slash_path_suggests_project() {
assert_eq!(
suggest_target("./scripts/deploy-staging.sh", "", "", None),
Suggestion::ProjectAllowlist
);
}
#[test]
fn generic_command_suggests_bundled_preset() {
assert_eq!(
suggest_target("make integration-test", "myproj", "feat/auth", None),
Suggestion::BundledPresetCandidate
);
}
#[test]
fn project_name_substring_suggests_project() {
assert_eq!(
suggest_target("myproj-cli --build", "myproj", "", None),
Suggestion::ProjectAllowlist
);
}
#[test]
fn branch_name_substring_suggests_project() {
assert_eq!(
suggest_target("deploy feat/auth", "", "feat/auth", None),
Suggestion::ProjectAllowlist
);
}
#[test]
fn worktree_root_substring_suggests_project() {
let root = Path::new("/home/me/wt/feature");
assert_eq!(
suggest_target("cat /home/me/wt/feature/notes", "", "", Some(root)),
Suggestion::ProjectAllowlist
);
}
#[test]
fn multiple_project_signals_tie_still_project() {
assert_eq!(
suggest_target("./run.sh myproj", "myproj", "feat/x", None),
Suggestion::ProjectAllowlist
);
}
#[test]
fn empty_project_and_branch_do_not_false_match() {
assert_eq!(
suggest_target("npm test", "", "", None),
Suggestion::BundledPresetCandidate
);
}
#[test]
fn aggregate_missing_file_is_empty() {
let tmp = TempDir::new().unwrap();
let path = log_path(tmp.path(), "paw-none");
let rows = aggregate(&path).unwrap();
assert!(rows.is_empty());
}
#[test]
fn aggregate_groups_counts_and_sorts_descending() {
let tmp = TempDir::new().unwrap();
let path = log_path(tmp.path(), "paw-x");
append_line(
&appr("a", "make integration-test", true, "2026-05-29T12:00:00Z"),
&path,
)
.unwrap();
append_line(
&appr("a", "podman build", true, "2026-05-29T12:01:00Z"),
&path,
)
.unwrap();
append_line(
&appr("a", "make integration-test", false, "2026-05-29T12:02:00Z"),
&path,
)
.unwrap();
append_line(
&appr("a", "./deploy.sh", true, "2026-05-29T12:03:00Z"),
&path,
)
.unwrap();
append_line(
&appr("a", "make integration-test", false, "2026-05-29T12:04:00Z"),
&path,
)
.unwrap();
append_line(
&appr("a", "podman build", false, "2026-05-29T12:05:00Z"),
&path,
)
.unwrap();
let rows = aggregate(&path).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].pattern, "make integration-test");
assert_eq!(rows[0].count, 3);
assert_eq!(rows[0].first_seen, "2026-05-29T12:00:00Z");
assert_eq!(rows[0].last_seen, "2026-05-29T12:04:00Z");
assert_eq!(rows[1].pattern, "podman build");
assert_eq!(rows[1].count, 2);
assert_eq!(rows[2].pattern, "./deploy.sh");
assert_eq!(rows[2].count, 1);
}
#[test]
fn aggregate_skips_malformed_lines() {
let tmp = TempDir::new().unwrap();
let path = log_path(tmp.path(), "paw-x");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"{not json}\n{\"timestamp\":\"2026-05-29T12:00:00Z\",\"agent_id\":\"a\",\"pattern\":\"ok\",\"first_seen\":true}\n\n",
)
.unwrap();
let rows = aggregate(&path).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].pattern, "ok");
}
#[test]
fn learning_record_has_permission_pattern_category_and_body() {
let msg = permission_pattern_learning(
"feat/a",
"make integration-test",
1,
Suggestion::BundledPresetCandidate,
Some("claude"),
SystemTime::UNIX_EPOCH,
);
let BrokerMessage::Learning { payload } = msg else {
panic!("expected Learning");
};
assert_eq!(payload.category, CATEGORY_PERMISSION_PATTERN);
assert_eq!(payload.body["pattern"], "make integration-test");
assert_eq!(payload.body["count_so_far"], 1);
assert_eq!(payload.body["suggested_target"], "bundled-preset");
assert_eq!(payload.body["cli"], "claude");
}
#[test]
fn extract_shell_command_from_prompt() {
let captured = "Bash command:\nmake integration-test\nrequires approval [y/N]";
assert_eq!(
extract_forwarded_pattern(captured).as_deref(),
Some("make integration-test")
);
}
#[test]
fn extract_skips_boilerplate_and_returns_command() {
let captured = "Do you want to proceed?\nrm -rf /tmp/foo\n[y/N]";
assert_eq!(
extract_forwarded_pattern(captured).as_deref(),
Some("rm -rf /tmp/foo")
);
}
#[test]
fn extract_strips_running_prefix() {
let captured = "do you want to proceed\nRunning ./scripts/deploy.sh";
assert_eq!(
extract_forwarded_pattern(captured).as_deref(),
Some("./scripts/deploy.sh")
);
}
#[test]
fn extract_file_op_path() {
let captured = "Do you want to allow this write to /etc/hosts?";
assert_eq!(
extract_forwarded_pattern(captured).as_deref(),
Some("/etc/hosts")
);
}
#[test]
fn extract_returns_none_for_marker_only() {
assert_eq!(extract_forwarded_pattern("requires approval\n[y/N]"), None);
}
fn recorder(tmp: &TempDir, enabled: bool, learnings: bool) -> ManualDecisionRecorder {
ManualDecisionRecorder::new(
log_path(tmp.path(), "paw-x"),
enabled,
learnings,
"myproj".to_string(),
Some("claude".to_string()),
)
}
#[test]
fn recorder_disabled_writes_nothing_and_emits_no_learning() {
let tmp = TempDir::new().unwrap();
let mut rec = recorder(&tmp, false, true);
let learning = rec.record_forwarded("feat/a", "Bash command:\nmake foo\n[y/N]");
assert!(learning.is_none(), "disabled recorder must not emit");
assert!(
!log_path(tmp.path(), "paw-x").exists(),
"disabled recorder must not write the log"
);
}
#[test]
fn recorder_first_sighting_emits_one_learning_repeat_emits_none() {
let tmp = TempDir::new().unwrap();
let mut rec = recorder(&tmp, true, true);
let first = rec.record_forwarded("feat/a", "Bash command:\nmake foo\n[y/N]");
let second = rec.record_forwarded("feat/a", "Bash command:\nmake foo\n[y/N]");
assert!(first.is_some(), "first sighting must emit a learning");
assert!(second.is_none(), "repeat sighting must not emit");
let rows = aggregate(&log_path(tmp.path(), "paw-x")).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].count, 2);
}
#[test]
fn recorder_logs_but_omits_learning_when_learnings_disabled() {
let tmp = TempDir::new().unwrap();
let mut rec = recorder(&tmp, true, false);
let learning = rec.record_forwarded("feat/a", "Bash command:\n./run.sh\n[y/N]");
assert!(
learning.is_none(),
"learnings disabled → no broker emission"
);
let rows = aggregate(&log_path(tmp.path(), "paw-x")).unwrap();
assert_eq!(rows.len(), 1, "but the JSONL line is still written");
assert_eq!(rows[0].pattern, "./run.sh");
}
#[test]
fn learning_record_omits_cli_when_absent() {
let msg = permission_pattern_learning(
"feat/a",
"./deploy.sh",
2,
Suggestion::ProjectAllowlist,
None,
SystemTime::UNIX_EPOCH,
);
let BrokerMessage::Learning { payload } = msg else {
panic!("expected Learning");
};
assert!(payload.body.get("cli").is_none());
assert_eq!(payload.body["suggested_target"], "project-allowlist");
}
}