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:
asepharyana
2026-07-11 21:25:30 +07:00
parent 2ded2d8bf1
commit f6389018f5
28 changed files with 1635 additions and 200 deletions
+1 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
-8
View File
@@ -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);
-15
View File
@@ -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
View File
@@ -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,
-28
View File
@@ -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())
-2
View File
@@ -1,6 +1,4 @@
pub mod actions;
pub mod commands;
pub mod event_loop;
#[expect(dead_code)]
pub mod shortsend;
pub mod stream;
-3
View File
@@ -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.
-4
View File
@@ -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
View File
@@ -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())
}
+4
View File
@@ -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,
}
}
-21
View File
@@ -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();
}
}
-6
View File
@@ -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>>,
-16
View File
@@ -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())
-10
View File
@@ -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;
-1
View File
@@ -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 {