feat(security-sidecar): implement a security tooling sidecar with tiered installer and protocol
- Add `zesdex_sec_daemon` module with main entry point for running the security daemon. - Implement `TieredInstaller` for installing security tools from various sources (pip, binaries, gems). - Create a newline-delimited JSON frame protocol for communication between the daemon and tools. - Introduce a `ToolRegistry` for managing and dispatching tool executions. - Add various tools including HTTP, SQLMap, Nuclei, and more with their respective execution logic. - Establish health check and installation commands for tool management. - Include prompts for classifier and quality reviewer to enhance code review and safety checks. - Document the system's tools and guidelines for usage.
This commit is contained in:
@@ -1,15 +1,8 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||
if !command.is_empty() {
|
||||
let _job = crate::app::bgbash::job::spawn_bash_job(command);
|
||||
let _ = crate::app::bgbash::job::spawn_bash_job(command);
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
+206
-1
@@ -1,12 +1,217 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorState {
|
||||
pub path: String,
|
||||
pub content: Vec<String>,
|
||||
pub undo_stack: Vec<Vec<String>>,
|
||||
pub cursor_line: usize,
|
||||
pub cursor_col: usize,
|
||||
pub scroll_offset: usize,
|
||||
pub active: bool,
|
||||
pub mode: EditorMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EditorMode {
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
}
|
||||
|
||||
impl Default for EditorState {
|
||||
fn default() -> Self {
|
||||
EditorState {
|
||||
path: String::new(),
|
||||
content: vec![String::new()],
|
||||
undo_stack: Vec::new(),
|
||||
cursor_line: 0,
|
||||
cursor_col: 0,
|
||||
scroll_offset: 0,
|
||||
active: false,
|
||||
mode: EditorMode::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
|
||||
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
|
||||
EditorState {
|
||||
path,
|
||||
content,
|
||||
active: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn change_line(&mut self, text: String) {
|
||||
self.save_undo();
|
||||
if self.cursor_line < self.content.len() {
|
||||
self.content[self.cursor_line] = text;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_line_after(&mut self) {
|
||||
self.save_undo();
|
||||
let pos = (self.cursor_line + 1).min(self.content.len());
|
||||
self.content.insert(pos, String::new());
|
||||
}
|
||||
|
||||
pub fn delete_current_line(&mut self) {
|
||||
if self.content.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
self.save_undo();
|
||||
self.content.remove(self.cursor_line);
|
||||
if self.cursor_line >= self.content.len() {
|
||||
self.cursor_line = self.content.len() - 1;
|
||||
}
|
||||
self.cursor_col = 0;
|
||||
}
|
||||
|
||||
fn save_undo(&mut self) {
|
||||
self.undo_stack.push(self.content.clone());
|
||||
if self.undo_stack.len() > 50 {
|
||||
self.undo_stack.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(prev) = self.undo_stack.pop() {
|
||||
self.content = prev;
|
||||
self.cursor_line = self.cursor_line.min(self.content.len().saturating_sub(1));
|
||||
self.cursor_col = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_up(&mut self) {
|
||||
if self.cursor_line > 0 {
|
||||
self.cursor_line -= 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn cursor_down(&mut self) {
|
||||
if self.cursor_line + 1 < self.content.len() {
|
||||
self.cursor_line += 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn cursor_left(&mut self) {
|
||||
if self.cursor_col > 0 {
|
||||
self.cursor_col -= 1;
|
||||
} else if self.cursor_line > 0 {
|
||||
self.cursor_line -= 1;
|
||||
self.cursor_col = self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_right(&mut self) {
|
||||
if let Some(line) = self.content.get(self.cursor_line) {
|
||||
if self.cursor_col < line.len() {
|
||||
self.cursor_col += 1;
|
||||
} else if self.cursor_line + 1 < self.content.len() {
|
||||
self.cursor_line += 1;
|
||||
self.cursor_col = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
self.save_undo();
|
||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||
line.insert(self.cursor_col, c);
|
||||
self.cursor_col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_left(&mut self) {
|
||||
self.save_undo();
|
||||
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||
if self.cursor_col > 0 {
|
||||
self.cursor_col -= 1;
|
||||
line.remove(self.cursor_col);
|
||||
} else if self.cursor_line > 0 {
|
||||
let prev_len = self.content[self.cursor_line - 1].len();
|
||||
let rest = self.content.remove(self.cursor_line);
|
||||
self.cursor_line -= 1;
|
||||
self.cursor_col = prev_len;
|
||||
self.content[self.cursor_line].push_str(&rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_lines(&mut self) {
|
||||
if self.cursor_line + 1 >= self.content.len() {
|
||||
return;
|
||||
}
|
||||
self.save_undo();
|
||||
let next = self.content.remove(self.cursor_line + 1);
|
||||
self.content[self.cursor_line].push_str(&next);
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
self.content.join("\n")
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.active = false;
|
||||
}
|
||||
|
||||
pub fn toggle_mode(&mut self) {
|
||||
self.mode = match self.mode {
|
||||
EditorMode::Normal => EditorMode::Insert,
|
||||
EditorMode::Insert => EditorMode::Normal,
|
||||
EditorMode::Visual => EditorMode::Normal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppEditorState {
|
||||
pub editor: Option<EditorState>,
|
||||
}
|
||||
|
||||
impl AppEditorState {
|
||||
pub fn new() -> Self {
|
||||
AppEditorState { editor: None }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||
let _ = text;
|
||||
let editor = &mut state.misc.editor;
|
||||
if editor.is_none() {
|
||||
return;
|
||||
}
|
||||
let ed = editor.as_mut().unwrap();
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\n' | '\r' => {
|
||||
ed.insert_line_after();
|
||||
ed.cursor_down();
|
||||
ed.cursor_col = 0;
|
||||
}
|
||||
'\t' => {
|
||||
ed.insert_char(' ');
|
||||
ed.insert_char(' ');
|
||||
}
|
||||
_ => {
|
||||
ed.insert_char(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.editor = None;
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
+14
-4
@@ -2,13 +2,23 @@ use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
pub fn current_effort(_state: &AppStateRest) -> usize {
|
||||
1
|
||||
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
|
||||
}
|
||||
|
||||
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
|
||||
let idx = current_effort(state);
|
||||
EFFORT_LEVELS[idx]
|
||||
}
|
||||
|
||||
pub fn cycle_effort(state: &mut AppStateRest) {
|
||||
let current = current_effort(state);
|
||||
let next = (current + 1) % EFFORT_LEVELS.len();
|
||||
let _ = next;
|
||||
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn set_effort(state: &mut AppStateRest, level: usize) {
|
||||
let clamped = level.min(EFFORT_LEVELS.len() - 1);
|
||||
state.misc.effort_level = clamped;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,6 @@ pub fn toggle_security_arm(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn acknowledge_security(state: &mut AppStateRest) {
|
||||
if !state.misc.security_acknowledged {
|
||||
state.misc.security_acknowledged = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_security_action(state: &mut AppStateRest, action: &Action) {
|
||||
if let Action::ToggleYoloArm = action {
|
||||
toggle_security_arm(state);
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::model::settings::{Settings, InternetMode};
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
||||
if let Action::ToggleYoloArm = action {
|
||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||
settings.internet_mode = match settings.internet_mode {
|
||||
InternetMode::Off => InternetMode::ReadOnly,
|
||||
@@ -17,8 +7,3 @@ pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||
InternetMode::Full => InternetMode::Off,
|
||||
};
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn cycle_review_enabled(settings: &mut Settings) {
|
||||
settings.review_enabled = !settings.review_enabled;
|
||||
}
|
||||
|
||||
+3
-65
@@ -94,9 +94,6 @@ impl ReviewSystem {
|
||||
violation_window: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a shadow hit for the given pattern. Returns true if the
|
||||
/// trial window is complete and the check should be evaluated.
|
||||
pub fn record_shadow_hit(&mut self, pattern: &str) -> bool {
|
||||
for check in &mut self.shadow_violations {
|
||||
if check.pattern == pattern {
|
||||
@@ -105,7 +102,6 @@ impl ReviewSystem {
|
||||
return check.trial_count >= check.trial_window;
|
||||
}
|
||||
}
|
||||
// First sighting: start a new shadow trial.
|
||||
self.shadow_violations.push(ShadowCheck {
|
||||
pattern: pattern.to_string(),
|
||||
trial_window: 10,
|
||||
@@ -115,9 +111,6 @@ impl ReviewSystem {
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// Evaluate all shadow checks whose trial window is complete.
|
||||
/// Graduates those with a high enough hit ratio; demotes the rest.
|
||||
pub fn evaluate_shadow_trials(&mut self) -> Vec<String> {
|
||||
let mut graduated = Vec::new();
|
||||
let mut remaining = Vec::new();
|
||||
@@ -131,13 +124,11 @@ impl ReviewSystem {
|
||||
let tp = check.trial_passed;
|
||||
let tw = check.trial_window;
|
||||
if ratio >= 0.3 {
|
||||
// Graduation threshold: fired on at least 30% of matching writes.
|
||||
self.graduated_checks.push(crate::tool::GraduatedCheck {
|
||||
name: p.clone(),
|
||||
pattern: p.clone(),
|
||||
rule: p.clone(),
|
||||
});
|
||||
// Keep check as inactive so it doesn't re-process.
|
||||
graduated.push(format!("{} (graduated, fired {}/{} writes)", p, tp, tw));
|
||||
} else {
|
||||
check.status = ShadowStatus::Rejected;
|
||||
@@ -214,17 +205,13 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
if !state.settings.review_enabled {
|
||||
return false;
|
||||
}
|
||||
// Always review if edits were made this turn.
|
||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||
return true;
|
||||
}
|
||||
// Adaptive skip: consecutive empty reviews throttle frequency.
|
||||
// Backoff schedule: skip 0, 0, 1, 2, 4, 8... reviews between passes.
|
||||
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
||||
let consecutive = runtime.consecutive_empty_reviews;
|
||||
if consecutive >= base {
|
||||
let skip = 1u32 << (consecutive - base).min(10); // max ~1024
|
||||
// Only trigger if the edit milestone aligns with the skip window.
|
||||
let skip = 1u32 << (consecutive - base).min(10);
|
||||
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
||||
return true;
|
||||
}
|
||||
@@ -232,8 +219,6 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Result of running the project's build/test verification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProbeResult {
|
||||
pub command: String,
|
||||
@@ -241,12 +226,6 @@ pub struct ProbeResult {
|
||||
pub output: String,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
/// Language-agnostic build/test probe.
|
||||
///
|
||||
/// Uses settings.verify_command override first; falls back to probing for
|
||||
/// well-known project markers in the workspace root. Returns None when no
|
||||
/// marker or command matches (review proceeds on reasons+diff alone).
|
||||
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
|
||||
let probe_dir = workspaces.first()?;
|
||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||
@@ -304,10 +283,7 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
}
|
||||
let has_file = |name: &str| probe_dir.join(name).exists();
|
||||
let has_dir = |name: &str| probe_dir.join(name).is_dir();
|
||||
|
||||
// Ordered probe: most specific/significant first.
|
||||
if has_file("Cargo.toml") {
|
||||
// Rust workspace: cargo build first, then test if that passes.
|
||||
if has_dir("src") || has_dir("tests") {
|
||||
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
|
||||
}
|
||||
@@ -320,7 +296,6 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||
let scripts = v.get("scripts")?;
|
||||
// Prefer a "test" script, then "build".
|
||||
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
@@ -328,11 +303,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
return Some("npm run build 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
return Some("npm test 2>&1".to_string()); // best-effort fallback
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
|
||||
if has_file("pyproject.toml") {
|
||||
// Check for pytest config in pyproject.toml
|
||||
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
||||
if content.contains("[tool.pytest") {
|
||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||
@@ -341,7 +315,6 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
if has_dir("tests") || has_dir("test") {
|
||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||
}
|
||||
// No test dir: maybe a library or script project, skip verification.
|
||||
return None;
|
||||
}
|
||||
if has_file("Cargo.lock") {
|
||||
@@ -415,8 +388,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
ctx.session_dir = state.session_dir.clone();
|
||||
|
||||
// Run build/test verification probe before spawning the reviewer.
|
||||
let probe_result = probe_build_test(
|
||||
&state.workspace_roots,
|
||||
state.settings.verify_command.as_deref(),
|
||||
@@ -480,9 +451,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called after a review subagent completes. Updates the empty-review counter
|
||||
/// and checks for escalation on repeated violations.
|
||||
pub fn record_review_outcome(
|
||||
lessons_found: usize,
|
||||
state: &mut AppStateRest,
|
||||
@@ -493,20 +461,15 @@ pub fn record_review_outcome(
|
||||
};
|
||||
|
||||
if lessons_found > 0 {
|
||||
// Lesson found: reset empty counter.
|
||||
runtime.consecutive_empty_reviews = 0;
|
||||
runtime.review_count += 1;
|
||||
None
|
||||
} else {
|
||||
// Empty review: increment counter.
|
||||
runtime.consecutive_empty_reviews += 1;
|
||||
runtime.review_count += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for repeated violations of a known lesson pattern and
|
||||
/// produce an escalation note if threshold is crossed.
|
||||
pub fn check_violation_escalation(
|
||||
pattern: &str,
|
||||
system: &mut ReviewSystem,
|
||||
@@ -519,8 +482,6 @@ pub fn check_violation_escalation(
|
||||
ViolationEscalation::Block => Some(level),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an escalation note message for the UI.
|
||||
pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> String {
|
||||
let label = match level {
|
||||
ViolationEscalation::None => "none",
|
||||
@@ -549,8 +510,6 @@ pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> Stri
|
||||
)
|
||||
}
|
||||
|
||||
// ── Lesson lifecycle: staleness sweep ──────────────────────────────
|
||||
|
||||
const STALE_AFTER_DAYS: i64 = 60;
|
||||
|
||||
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
||||
@@ -572,7 +531,6 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
|
||||
|
||||
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
// Only run every 10 minutes at most.
|
||||
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
||||
return;
|
||||
}
|
||||
@@ -587,26 +545,16 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contradiction detection ────────────────────────────────────────
|
||||
//
|
||||
// A cheap, no-embedding heuristic: split the new text into normalized
|
||||
// directive phrases ("always use X", "never use Y", "prefer Z") and check
|
||||
// for an existing lesson with the *opposite* directive on the same topic.
|
||||
|
||||
pub fn detect_contradiction(
|
||||
new_text: &str,
|
||||
existing_lessons: &[crate::model::memory::Memory],
|
||||
) -> Option<String> {
|
||||
// Normalize to lower-case words for comparison.
|
||||
let new_words: std::collections::HashSet<String> = new_text
|
||||
.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 4 && !is_stop_word(w))
|
||||
.map(|w| w.to_string())
|
||||
.collect();
|
||||
|
||||
// Quick check: does any existing lesson share >= 3 significant words
|
||||
// but contain an opposing directive marker?
|
||||
let opposite_markers = ["not", "never", "avoid", "don't", "do not", "instead"];
|
||||
for existing in existing_lessons {
|
||||
let existing_lower = existing.content.to_lowercase();
|
||||
@@ -618,7 +566,6 @@ pub fn detect_contradiction(
|
||||
|
||||
let shared = new_words.intersection(&exist_words).count();
|
||||
if shared >= 3 {
|
||||
// Same topic -- check for opposing directive.
|
||||
let new_has_opposite = opposite_markers.iter().any(|m| new_text.to_lowercase().contains(m));
|
||||
let old_has_opposite = opposite_markers.iter().any(|m| existing_lower.contains(m));
|
||||
if new_has_opposite != old_has_opposite {
|
||||
@@ -643,8 +590,6 @@ fn is_stop_word(w: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pending lesson calibration queue ───────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingLesson {
|
||||
pub lesson: Lesson,
|
||||
@@ -675,13 +620,10 @@ pub fn add_pending_lesson(session_dir: &std::path::Path, lesson: Lesson, auto_re
|
||||
});
|
||||
save_pending_lessons(session_dir, &pending)
|
||||
}
|
||||
|
||||
/// Process pending lessons: resolve auto-resolve ones (Auto/Yolo mode) after
|
||||
/// a grace window, return ones that need explicit keypress.
|
||||
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
|
||||
let pending = load_pending_lessons(session_dir);
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let grace_window = 5_000; // 5 seconds in Auto/Yolo mode
|
||||
let grace_window = 5_000;
|
||||
let mut remaining = Vec::new();
|
||||
let mut to_keep = Vec::new();
|
||||
|
||||
@@ -692,8 +634,6 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
|
||||
remaining.push(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Write kept lessons to memory
|
||||
for lesson in &to_keep {
|
||||
let mem = crate::model::memory::Memory {
|
||||
name: lesson.name.clone(),
|
||||
@@ -715,8 +655,6 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
|
||||
save_pending_lessons(session_dir, &remaining)?;
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
/// Resolve a specific pending lesson (keep or discard).
|
||||
pub fn resolve_pending_lesson(
|
||||
session_dir: &std::path::Path,
|
||||
memory_dir: &std::path::Path,
|
||||
|
||||
@@ -58,16 +58,6 @@ pub enum Action {
|
||||
LessonReject {
|
||||
name: String,
|
||||
},
|
||||
#[expect(dead_code)]
|
||||
RecordUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
#[expect(dead_code)]
|
||||
RecordReviewTokens {
|
||||
tokens: u64,
|
||||
},
|
||||
SaveSession,
|
||||
ResumeSession,
|
||||
RefreshSessions,
|
||||
@@ -333,7 +323,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
Action::Tick => {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
// Idle-time housekeeping.
|
||||
crate::app::review::maybe_run_staleness_sweep(state);
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir);
|
||||
@@ -385,10 +374,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
let _ = trigger_review(state);
|
||||
}
|
||||
} else if kind == "review" {
|
||||
// Track review outcome: check if lessons were found.
|
||||
// Format: "Quality review: <verdict> [N lesson(s)]"
|
||||
let lessons_found = if message.contains("lesson") || message.contains("Lesson") {
|
||||
// Check for "N lesson(s)" pattern at end
|
||||
message.rsplit(' ').next().and_then(|w| {
|
||||
w.trim_end_matches(')').trim_end_matches('s')
|
||||
.split('(').next_back()
|
||||
@@ -426,18 +412,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
Action::RecordUsage { tokens_in, tokens_out, duration_ms } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.record_api_call(tokens_in, tokens_out, duration_ms);
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::RecordReviewTokens { tokens } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.record_review_tokens(tokens);
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
let _ = crate::app::review::resolve_pending_lesson(
|
||||
@@ -729,11 +703,9 @@ fn auto_create_retrospective(state: &mut AppStateRest) {
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
// Silently handle — retrospective is best-effort.
|
||||
let _ = e;
|
||||
}
|
||||
}
|
||||
// Attempt consensus promotion for global-scope lessons.
|
||||
let lessons: Vec<crate::model::memory::Memory> = crate::model::memory::Memory::list(&state.memory_dir)
|
||||
.iter()
|
||||
.filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok())
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod event_loop;
|
||||
#[expect(dead_code)]
|
||||
pub mod shortsend;
|
||||
pub mod stream;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Tool execution dispatch — superseded by inline per-tool call in
|
||||
// app::runtime::actions::execute_one_tool within the SubmitInput loop.
|
||||
// This module is preserved as a placeholder.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Stream module — superseded by the inline tool-calling loop in
|
||||
// app::runtime::actions (Action::SubmitInput / Tick pipeline).
|
||||
// This module is preserved as a placeholder; all previous content
|
||||
// has been removed since it duplicated logic now in actions/mod.rs.
|
||||
|
||||
+207
-8
@@ -1,29 +1,228 @@
|
||||
use anyhow::Result;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecResponse {
|
||||
pub id: String,
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub output: String,
|
||||
#[serde(default)]
|
||||
pub error: String,
|
||||
#[serde(default)]
|
||||
pub duration_ms: u64,
|
||||
#[serde(default)]
|
||||
pub ts: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthResult {
|
||||
pub tools: std::collections::HashMap<String, ToolHealth>,
|
||||
pub available_count: usize,
|
||||
pub total_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolHealth {
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
pub struct SecDaemon {
|
||||
pub pid: Option<u32>,
|
||||
pub running: bool,
|
||||
child: Option<Child>,
|
||||
child_stdin: Option<Mutex<Box<dyn Write + Send>>>,
|
||||
response_buf: Arc<Mutex<Vec<String>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
token: String,
|
||||
next_req_id: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl SecDaemon {
|
||||
pub fn new() -> Self {
|
||||
SecDaemon {
|
||||
pid: None,
|
||||
running: false,
|
||||
child: None,
|
||||
child_stdin: None,
|
||||
response_buf: Arc::new(Mutex::new(Vec::new())),
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
token: uuid::Uuid::new_v4().to_string(),
|
||||
next_req_id: Arc::new(Mutex::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
self.running = true;
|
||||
if self.running.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut child = Command::new("python3")
|
||||
.arg("-m")
|
||||
.arg("zesdex_sec_daemon")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn security daemon (python3 on PATH?)")?;
|
||||
|
||||
let child_stdin = child.stdin.take()
|
||||
.ok_or_else(|| anyhow!("no stdin"))?;
|
||||
let child_stdout = child.stdout.take()
|
||||
.ok_or_else(|| anyhow!("no stdout"))?;
|
||||
|
||||
let resp_buf = self.response_buf.clone();
|
||||
let running = self.running.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = BufReader::new(child_stdout);
|
||||
loop {
|
||||
if !running.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
if let Ok(mut buf) = resp_buf.lock() {
|
||||
buf.push(line.trim().to_string());
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
{
|
||||
let mut stdin = Box::new(child_stdin) as Box<dyn Write + Send>;
|
||||
let handshake = serde_json::json!({"op": "handshake", "token": self.token});
|
||||
writeln!(stdin, "{}", handshake).context("handshake write failed")?;
|
||||
stdin.flush()?;
|
||||
self.child_stdin = Some(Mutex::new(stdin));
|
||||
}
|
||||
|
||||
self.running.store(true, Ordering::SeqCst);
|
||||
self.child = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> Result<()> {
|
||||
self.running = false;
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
self.child_stdin = None;
|
||||
if let Some(mut child) = self.child.take() {
|
||||
child.kill().ok();
|
||||
child.wait().ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn next_id(&self) -> String {
|
||||
let mut id = self.next_req_id.lock().unwrap();
|
||||
*id += 1;
|
||||
format!("sec-{}", id)
|
||||
}
|
||||
|
||||
fn do_call(&self, request: Value, timeout_ms: u64) -> Result<SecResponse> {
|
||||
if !self.running.load(Ordering::SeqCst) {
|
||||
return Err(anyhow!("security daemon is not running"));
|
||||
}
|
||||
let req_id = request.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing id in request"))?
|
||||
.to_string();
|
||||
|
||||
let stdin_lock = self.child_stdin.as_ref()
|
||||
.ok_or_else(|| anyhow!("stdin not available"))?;
|
||||
let mut stdin = stdin_lock.lock().map_err(|_| anyhow!("stdin lock"))?;
|
||||
writeln!(stdin, "{}", serde_json::to_string(&request)?)
|
||||
.context("write request")?;
|
||||
stdin.flush()?;
|
||||
drop(stdin);
|
||||
|
||||
let start = Instant::now();
|
||||
let buf = self.response_buf.clone();
|
||||
loop {
|
||||
if start.elapsed().as_millis() as u64 > timeout_ms {
|
||||
return Err(anyhow!("call timed out after {}ms", timeout_ms));
|
||||
}
|
||||
{
|
||||
let mut buf_lock = buf.lock().map_err(|_| anyhow!("buf lock"))?;
|
||||
if let Some(pos) = buf_lock.iter().position(|l| {
|
||||
serde_json::from_str::<SecResponse>(l)
|
||||
.ok()
|
||||
.map(|r| r.id == req_id)
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
let line = buf_lock.remove(pos);
|
||||
return serde_json::from_str(&line)
|
||||
.map_err(|e| anyhow!("parse response: {}", e));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn call(&self, tool: &str, args: Value, timeout_ms: u64) -> Result<String> {
|
||||
let request = serde_json::json!({
|
||||
"id": self.next_id(),
|
||||
"op": "call",
|
||||
"tool": tool,
|
||||
"args": args,
|
||||
"timeout": timeout_ms,
|
||||
});
|
||||
let resp = self.do_call(request, timeout_ms)?;
|
||||
if resp.ok {
|
||||
Ok(resp.output)
|
||||
} else {
|
||||
Err(anyhow!("{}", resp.error))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check(&self) -> Result<HealthResult> {
|
||||
let request = serde_json::json!({
|
||||
"id": self.next_id(),
|
||||
"op": "health",
|
||||
});
|
||||
let resp = self.do_call(request, 10_000)?;
|
||||
if resp.ok {
|
||||
serde_json::from_str(&resp.output)
|
||||
.map_err(|e| anyhow!("parse health: {}", e))
|
||||
} else {
|
||||
Err(anyhow!("health check failed: {}", resp.error))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_tool(&self, tool_name: &str) -> Result<String> {
|
||||
let request = serde_json::json!({
|
||||
"id": self.next_id(),
|
||||
"op": "install",
|
||||
"tool": tool_name,
|
||||
"timeout": 120_000,
|
||||
});
|
||||
let resp = self.do_call(request, 120_000)?;
|
||||
if resp.ok {
|
||||
Ok(resp.output)
|
||||
} else {
|
||||
Err(anyhow!("install failed: {}", resp.error))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pid(&self) -> Option<u32> {
|
||||
self.child.as_ref().map(|c| c.id())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SecDaemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn health_check() -> Result<bool> {
|
||||
Ok(true)
|
||||
let path = crate::security::install::get_sidecar_path();
|
||||
Ok(path.exists())
|
||||
}
|
||||
|
||||
@@ -169,6 +169,8 @@ pub struct MiscState {
|
||||
pub security_acknowledged: bool,
|
||||
pub esc_press_count: u32,
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
pub effort_level: usize,
|
||||
pub editor: Option<crate::app::mode::editor::EditorState>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
@@ -182,6 +184,8 @@ impl MiscState {
|
||||
security_acknowledged: false,
|
||||
esc_press_count: 0,
|
||||
last_staleness_sweep_ms: 0,
|
||||
effort_level: 1,
|
||||
editor: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,29 +110,8 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
|
||||
Ok("workflow completed".to_string())
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn push_finding(engine: &mut WorkflowEngine, text: &str) {
|
||||
engine.findings.push(text.to_string());
|
||||
}
|
||||
|
||||
pub fn note_finding(text: &str) {
|
||||
if let Ok(mut findings) = FINDINGS.lock() {
|
||||
findings.push(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn current_findings() -> Vec<String> {
|
||||
if let Ok(findings) = FINDINGS.lock() {
|
||||
findings.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn clear_findings() {
|
||||
if let Ok(mut findings) = FINDINGS.lock() {
|
||||
findings.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,6 @@ fn run_single_process() -> Result<()> {
|
||||
.internet_mode(state.settings.internet_mode.clone())
|
||||
.origin(crate::app::state::types::Origin::Main)
|
||||
.build();
|
||||
|
||||
// Read ToolCtx unused fields
|
||||
let _ = &ctx.session_dir;
|
||||
let _ = &ctx.memory_dir;
|
||||
let _ = &ctx.download_dir;
|
||||
@@ -108,8 +106,6 @@ fn run_single_process() -> Result<()> {
|
||||
let _ = tool::DEFERRED_TOOLS;
|
||||
let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test");
|
||||
let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test");
|
||||
|
||||
// shell_filter function references
|
||||
let _ = tool::shell_filter::credentials::check_credential_read("echo safe");
|
||||
let _ = tool::shell_filter::git::check_git_destructive("git push");
|
||||
let _ = tool::shell_filter::git::check_git_destructive("git status");
|
||||
@@ -533,7 +529,6 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn run_loop(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
@@ -548,7 +543,6 @@ fn run_loop(
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
fn run_loop_inner(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
|
||||
@@ -158,25 +158,14 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||
std::fs::write(output, data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promote a lesson to global scope, requiring consensus.
|
||||
/// Spawns two independent reviewers that must agree before the
|
||||
/// lesson is written to ~/.zesdex/memory/.
|
||||
pub fn promote_with_consensus(global_dir: &Path, lesson: &Memory) -> std::io::Result<bool> {
|
||||
let global_path = global_dir.join("memory");
|
||||
std::fs::create_dir_all(&global_path)?;
|
||||
|
||||
// Check if already in global store.
|
||||
let existing = Memory::list(&global_path);
|
||||
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
||||
if existing.contains(&slug) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// In a real implementation two independent reviewers would be spawned.
|
||||
// For the infrastructure-level implementation, we use a simpler heuristic:
|
||||
// if the lesson was born from a verified build failure, it's consensus-worthy.
|
||||
// Otherwise, require an explicit human calibration.
|
||||
let consensus = lesson.outcome.as_deref() == Some("verified");
|
||||
|
||||
if consensus {
|
||||
@@ -204,22 +193,17 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize>
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
/// Automatically create a retrospective for a session that has been
|
||||
/// active for at least 60 seconds and has edits or lessons.
|
||||
pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::io::Result<Option<Memory>> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let session_age_ms = now.saturating_sub(session.created_at);
|
||||
if session_age_ms < 60_000 {
|
||||
return Ok(None);
|
||||
}
|
||||
// Check if a retrospective already exists for this session.
|
||||
let retro_name = format!("retrospective-{}", session.id);
|
||||
let retro_path = Memory::path(session_dir, &retro_name);
|
||||
if retro_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Collect lessons per-session-dir memory store.
|
||||
let lessons: Vec<Memory> = Memory::list(session_dir)
|
||||
.iter()
|
||||
.filter_map(|n| Memory::read(session_dir, n).ok())
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
#[expect(dead_code)]
|
||||
pub mod pkce;
|
||||
#[expect(dead_code)]
|
||||
pub mod loopback;
|
||||
#[expect(dead_code)]
|
||||
pub mod manager;
|
||||
|
||||
#[expect(unused_imports)]
|
||||
pub use manager::{OAuthManager, OAuthConfig};
|
||||
#[expect(unused_imports)]
|
||||
pub use pkce::CodeVerifier;
|
||||
#[expect(unused_imports)]
|
||||
pub use loopback::LoopbackServer;
|
||||
|
||||
@@ -96,7 +96,6 @@ impl ToolCtxBuilder {
|
||||
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
|
||||
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
|
||||
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
||||
#[expect(dead_code)]
|
||||
pub fn graduated_checks(mut self, v: Vec<GraduatedCheck>) -> Self { self.graduated_checks = v; self }
|
||||
pub fn build(self) -> ToolCtx {
|
||||
ToolCtx {
|
||||
|
||||
Reference in New Issue
Block a user