diff --git a/security-sidecar/requirements.txt b/security-sidecar/requirements.txt new file mode 100644 index 0000000..e8762e1 --- /dev/null +++ b/security-sidecar/requirements.txt @@ -0,0 +1,6 @@ +requests>=2.31.0 +# Optional extras (install with: pip install zesdex-security-daemon[full]) +# pycryptodome>=3.20.0 +# factordb-python>=2.0.0 +# pwntools>=4.12.0 +# ropper>=1.13.0 diff --git a/security-sidecar/setup.cfg b/security-sidecar/setup.cfg new file mode 100644 index 0000000..dfcb227 --- /dev/null +++ b/security-sidecar/setup.cfg @@ -0,0 +1,29 @@ +[metadata] +name = zesdex-security-daemon +version = 0.1.0 +description = Security tooling sidecar for zesdex — authorized pentesting/CTF/research tool dispatch +author = asepharyana +author_email = superaseph@gmail.com + +[options] +packages = zesdex_sec_daemon +install_requires = + requests>=2.31.0 + +[options.extras_require] +web = + sqlmap>=1.8.0 + python-nmap>=0.7.1 +crypto = + pycryptodome>=3.20.0 + factordb-python>=2.0.0 +re = + wasm-decompile>=0.5.0 +pwn = + pwntools>=4.12.0 + ropper>=1.13.0 +full = + %(web)s + %(crypto)s + %(re)s + %(pwn)s diff --git a/security-sidecar/zesdex_sec_daemon/__init__.py b/security-sidecar/zesdex_sec_daemon/__init__.py new file mode 100644 index 0000000..2af5533 --- /dev/null +++ b/security-sidecar/zesdex_sec_daemon/__init__.py @@ -0,0 +1,17 @@ +"""zesdex-security-daemon: authorized security tooling sidecar. + +Newline-delimited JSON frame protocol over stdin/stdout. +Single-threaded serialized dispatch with wall-clock timeout. +""" + +from .protocol import run_daemon, SecProtocolError +from .tools import ToolRegistry, ToolResult +from .installer import TieredInstaller + +__all__ = [ + "run_daemon", + "SecProtocolError", + "ToolRegistry", + "ToolResult", + "TieredInstaller", +] diff --git a/security-sidecar/zesdex_sec_daemon/__main__.py b/security-sidecar/zesdex_sec_daemon/__main__.py new file mode 100644 index 0000000..37651a1 --- /dev/null +++ b/security-sidecar/zesdex_sec_daemon/__main__.py @@ -0,0 +1,25 @@ +"""Entry point: run the security daemon reading JSON frames from stdin.""" + +import sys +from .protocol import run_daemon + + +def main(): + if "--install" in sys.argv: + from .installer import TieredInstaller + installer = TieredInstaller() + result = installer.install_all() + print(result.model_dump_json()) + return + if "--health" in sys.argv: + from .tools import ToolRegistry + registry = ToolRegistry() + health = registry.health_check() + import json + print(json.dumps(health)) + return + run_daemon(sys.stdin, sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/security-sidecar/zesdex_sec_daemon/installer.py b/security-sidecar/zesdex_sec_daemon/installer.py new file mode 100644 index 0000000..d8e9199 --- /dev/null +++ b/security-sidecar/zesdex_sec_daemon/installer.py @@ -0,0 +1,241 @@ +"""Tiered installer for security sidecar tools. + +Installation tiers: + pip — Python packages (pycryptodome, pwntools, factordb-python, etc.) + binary — Pre-built GitHub release binaries (nuclei, ffuf, dalfox, etc.) + gem — Ruby gems (zap-cli, etc.) + detect — Manual-detect-only (z3, sage, etc. — user must install themselves) +""" + +import json +import os +import platform +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class InstallResult: + ok: bool = False + message: str = "" + installed: list[str] = field(default_factory=list) + failed: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + + +class TieredInstaller: + """Multi-tier installer for security tools. + + Detects the current platform and tries each tier in order of + preference: pip -> binary -> gem -> detect (manual only). + """ + + def __init__(self, target_dir: Optional[str] = None): + self.target_dir = Path(target_dir or self._default_bin_dir()) + self.target_dir.mkdir(parents=True, exist_ok=True) + self._pip_available = shutil.which("pip3") is not None or shutil.which("pip") is not None + self._gem_available = shutil.which("gem") is not None + self._arch = platform.machine() + self._os = platform.system().lower() + + def _default_bin_dir(self) -> str: + if self._os == "linux": + return "/usr/local/bin" + return os.path.expanduser("~/.local/bin") + + # ── pip tier ──────────────────────────────────────────────────── + + def _pip_install(self, pkg: str) -> bool: + pip = shutil.which("pip3") or shutil.which("pip") + if not pip: + return False + try: + result = subprocess.run( + [pip, "install", "--quiet", pkg], + capture_output=True, text=True, timeout=120, + ) + return result.returncode == 0 + except subprocess.TimeoutExpired: + return False + except Exception: + return False + + # ── GitHub release binary tier ────────────────────────────────── + + def _download_gh_release( + self, repo: str, asset_pattern: str, + ) -> Optional[Path]: + api_url = f"https://api.github.com/repos/{repo}/releases/latest" + try: + req = urllib.request.Request(api_url, headers={ + "Accept": "application/json", + "User-Agent": "zesdex-security-daemon/0.1.0", + }) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + assets = data.get("assets", []) + os_arch_tag = f"{self._os}_{self._arch}" + for asset in assets: + name = asset["name"] + if asset_pattern.replace("{os_arch}", os_arch_tag) in name: + download_url = asset["browser_download_url"] + break + else: + # Try without os_arch matching, just look for the pattern. + candidates = [a for a in assets if asset_pattern.split("/")[0] in a["name"]] + if not candidates: + return None + download_url = candidates[0]["browser_download_url"] + + with tempfile.NamedTemporaryFile(suffix=".tmp", delete=False) as tmp: + with urllib.request.urlopen(download_url, timeout=120) as dl: + tmp.write(dl.read()) + tmppath = tmp.name + + if download_url.endswith(".tar.gz"): + extract_dir = tempfile.mkdtemp() + with tarfile.open(tmppath, "r:gz") as tar: + tar.extractall(path=extract_dir) + os.unlink(tmppath) + # Find the binary in extracted files. + for root, _dirs, files in os.walk(extract_dir): + for fname in files: + if fname == asset_pattern.split("/")[0].replace(".tar.gz", ""): + return Path(root) / fname + return None + else: + result = Path(tmppath) + result.chmod(0o755) + return result + except Exception: + return None + + def _install_binary(self, name: str, repo: str, asset_pattern: str) -> bool: + downloaded = self._download_gh_release(repo, asset_pattern) + if downloaded is None: + return False + dest = self.target_dir / name + try: + shutil.move(str(downloaded), str(dest)) + dest.chmod(0o755) + return True + except Exception: + return False + + # ── Gem tier ──────────────────────────────────────────────────── + + def _gem_install(self, gem_name: str) -> bool: + if not self._gem_available: + return False + try: + result = subprocess.run( + ["gem", "install", "--quiet", gem_name], + capture_output=True, text=True, timeout=120, + ) + return result.returncode == 0 + except Exception: + return False + + # ── Tool-specific installers ──────────────────────────────────── + + def _install_pip_tools(self) -> list[str]: + pkgs = [ + "pycryptodome", + "pwntools", + "requests", + "factordb-python", + ] + installed = [] + for pkg in pkgs: + if self._pip_install(pkg): + installed.append(pkg) + return installed + + def _install_binary_tools(self) -> list[str]: + targets = [ + ("nuclei", "projectdiscovery/nuclei", "nuclei_{os_arch}.tar.gz"), + ("ffuf", "ffuf/ffuf", "ffuf_{os_arch}.tar.gz"), + ("dalfox", "hahwul/dalfox", "dalfox_{os_arch}.tar.gz"), + ("httpx", "projectdiscovery/httpx", "httpx_{os_arch}.tar.gz"), + ] + installed = [] + for name, repo, pattern in targets: + if shutil.which(name) is not None: + installed.append(name) + continue + if self._install_binary(name, repo, pattern): + installed.append(name) + return installed + + def _install_gem_tools(self) -> list[str]: + installed = [] + if self._gem_install("zap-cli"): + installed.append("zap-cli") + return installed + + # ── Public API ────────────────────────────────────────────────── + + def install_all(self) -> InstallResult: + result = InstallResult() + try: + pip_ok = self._install_pip_tools() + result.installed.extend(pip_ok) + except Exception as e: + result.failed.append(f"pip: {e}") + + try: + bin_ok = self._install_binary_tools() + result.installed.extend(bin_ok) + except Exception as e: + result.failed.append(f"binary: {e}") + + try: + gem_ok = self._install_gem_tools() + result.installed.extend(gem_ok) + except Exception as e: + result.failed.append(f"gem: {e}") + + result.ok = True + result.message = ( + f"Installed {len(result.installed)} tool(s): " + f"{', '.join(result.installed)}" + ) + return result + + def install_tool(self, tool_name: str) -> dict: + """Install a single tool by name. Returns {"ok": bool, "message": str}.""" + pip_map = { + "pycryptodome": "pycryptodome", + "pwntools": "pwntools", + "factordb": "factordb-python", + "requests": "requests", + } + binary_map = { + "nuclei": ("projectdiscovery/nuclei", "nuclei_{os_arch}.tar.gz"), + "ffuf": ("ffuf/ffuf", "ffuf_{os_arch}.tar.gz"), + "dalfox": ("hahwul/dalfox", "dalfox_{os_arch}.tar.gz"), + "httpx": ("projectdiscovery/httpx", "httpx_{os_arch}.tar.gz"), + "sqlmap": ("sqlmapproject/sqlmap", "sqlmap.tar.gz"), + } + gem_map = { + "zap-cli": "zap-cli", + } + + if tool_name in pip_map: + ok = self._pip_install(pip_map[tool_name]) + return {"ok": ok, "message": f"pip install {pip_map[tool_name]}: {'ok' if ok else 'failed'}"} + if tool_name in binary_map: + repo, pattern = binary_map[tool_name] + ok = self._install_binary(tool_name, repo, pattern) + return {"ok": ok, "message": f"binary install {tool_name}: {'ok' if ok else 'failed'}"} + if tool_name in gem_map: + ok = self._gem_install(gem_map[tool_name]) + return {"ok": ok, "message": f"gem install {gem_map[tool_name]}: {'ok' if ok else 'failed'}"} + return {"ok": False, "message": f"no installer available for {tool_name}; may need manual install"} diff --git a/security-sidecar/zesdex_sec_daemon/protocol.py b/security-sidecar/zesdex_sec_daemon/protocol.py new file mode 100644 index 0000000..9f964b6 --- /dev/null +++ b/security-sidecar/zesdex_sec_daemon/protocol.py @@ -0,0 +1,173 @@ +"""Newline-delimited JSON frame protocol for the security sidecar. + +Frame format: one JSON object per line, terminated by LF. +Request: {"id": "", "op": "call"|"health"|"install", "tool": "", "args": {...}, "timeout": } +Response: {"id": "", "ok": true, "output": "..."} | {"id": "", "ok": false, "error": "..."} +""" + +import json +import sys +import traceback +from typing import TextIO, Optional +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +class SecProtocolError(Exception): + """Raised on malformed frames or protocol violations.""" + + +@dataclass +class SecRequest: + req_id: str + op: str # "call" | "health" | "install" + tool: str = "" + args: dict = field(default_factory=dict) + timeout_ms: int = 30_000 + + @classmethod + def parse(cls, line: str) -> "SecRequest": + line = line.strip() + if not line: + raise SecProtocolError("empty line") + try: + data = json.loads(line) + except json.JSONDecodeError as e: + raise SecProtocolError(f"invalid JSON: {e}") from e + req_id = data.get("id") + op = data.get("op") + if not isinstance(req_id, str) or not req_id: + raise SecProtocolError("missing or invalid 'id'") + if op not in ("call", "health", "install"): + raise SecProtocolError(f"unknown op: {op!r}") + return cls( + req_id=req_id, + op=op, + tool=data.get("tool", ""), + args=data.get("args", {}), + timeout_ms=data.get("timeout", 30_000), + ) + + +@dataclass +class SecResponse: + req_id: str + ok: bool + output: str = "" + error: str = "" + duration_ms: int = 0 + + def to_json(self) -> str: + obj = {"id": self.req_id, "ok": self.ok, "ts": datetime.now(timezone.utc).isoformat()} + if self.ok: + obj["output"] = self.output + else: + obj["error"] = self.error + obj["duration_ms"] = self.duration_ms + return json.dumps(obj, ensure_ascii=False) + + +class FrameReader: + """Reads one JSON line from a buffered stream, enforcing a 64 MiB hard cap.""" + + MAX_FRAME_BYTES = 64 * 1024 * 1024 + + def __init__(self, stream: TextIO): + self._stream = stream + + def read_line(self) -> Optional[str]: + line = self._stream.readline() + if not line: + return None + if len(line) > self.MAX_FRAME_BYTES: + raise SecProtocolError(f"frame exceeds {self.MAX_FRAME_BYTES} byte limit") + return line + + +def make_handshake_frame(token: str) -> str: + return json.dumps({"op": "handshake", "token": token}) + + +def verify_handshake(line: str, expected_token: str) -> bool: + try: + data = json.loads(line.strip()) + return data.get("op") == "handshake" and data.get("token") == expected_token + except (json.JSONDecodeError, KeyError): + return False + + +def run_daemon(stdin: TextIO, stdout: TextIO, token: str = "", registry=None, timeout_cap_ms: int = 300_000): + """Read requests from stdin, dispatch to tool registry, write responses to stdout.""" + from .tools import ToolRegistry + registry = registry or ToolRegistry() + reader = FrameReader(stdin) + first = reader.read_line() + if first is None: + return + if token: + if not verify_handshake(first, token): + err = json.dumps({"ok": False, "error": "handshake failed"}) + stdout.write(err + "\n") + stdout.flush() + return + else: + try: + req = SecRequest.parse(first) + _handle_request(req, registry, stdout, timeout_cap_ms) + except SecProtocolError as e: + _write_error("init", str(e), stdout) + + while True: + line = reader.read_line() + if line is None: + break + if not line.strip(): + continue + try: + req = SecRequest.parse(line) + except SecProtocolError as e: + _write_error("unknown", str(e), stdout) + continue + _handle_request(req, registry, stdout, timeout_cap_ms) + + +def _handle_request(req: SecRequest, registry, stdout: TextIO, global_cap_ms: int): + from .tools import ToolResult + start = datetime.now(timezone.utc) + effective_timeout = min(req.timeout_ms, global_cap_ms) + try: + if req.op == "health": + health = registry.health_check() + resp = SecResponse(req_id=req.req_id, ok=True, output=json.dumps(health)) + elif req.op == "install": + installer_cls = None + try: + from .installer import TieredInstaller + installer_cls = TieredInstaller + except ImportError: + pass + if installer_cls: + installer = installer_cls() + result = installer.install_tool(req.tool) + resp = SecResponse(req_id=req.req_id, ok=result["ok"], output=result.get("message", "")) + else: + resp = SecResponse(req_id=req.req_id, ok=False, error="installer not available") + else: + result: ToolResult = registry.run(req.tool, req.args, timeout_ms=effective_timeout) + if result.ok: + resp = SecResponse(req_id=req.req_id, ok=True, output=result.output) + else: + resp = SecResponse(req_id=req.req_id, ok=False, error=result.error) + except Exception as exc: + resp = SecResponse(req_id=req.req_id, ok=False, error=f"dispatch error: {exc}") + traceback.print_exc(file=sys.stderr) + elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + resp.duration_ms = elapsed + stdout.write(resp.to_json() + "\n") + stdout.flush() + + +def _write_error(req_id: str, msg: str, stdout: TextIO): + resp = SecResponse(req_id=req_id, ok=False, error=msg) + stdout.write(resp.to_json() + "\n") + stdout.flush() diff --git a/security-sidecar/zesdex_sec_daemon/tools.py b/security-sidecar/zesdex_sec_daemon/tools.py new file mode 100644 index 0000000..09c7bf0 --- /dev/null +++ b/security-sidecar/zesdex_sec_daemon/tools.py @@ -0,0 +1,602 @@ +"""Tool registry and dispatch for the security sidecar. + +Each tool is a callable(subprocess_args, timeout_ms) -> ToolResult. +The registry maps tool names to implementations and provides health checking. +""" + +import json +import os +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile +import threading +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + + +@dataclass +class ToolResult: + ok: bool + output: str = "" + error: str = "" + returncode: int = -1 + timed_out: bool = False + duration_ms: int = 0 + + +def _run_subprocess( + cmd: list[str], + stdin_data: Optional[bytes] = None, + timeout_ms: int = 30_000, + cwd: Optional[Path] = None, + env: Optional[dict[str, str]] = None, +) -> ToolResult: + start = datetime.now(timezone.utc) + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=cwd, + env=env, + preexec_fn=lambda: signal.signal(signal.SIGXCPU, signal.SIG_DFL), + ) + stdout_b, stderr_b = b"", b"" + done = threading.Event() + + def _reader(): + nonlocal stdout_b, stderr_b + try: + stdout_b, stderr_b = proc.communicate(input=stdin_data, timeout=timeout_ms / 1000) + except subprocess.TimeoutExpired: + proc.kill() + stdout_b, stderr_b = proc.communicate() + finally: + done.set() + + reader_thread = threading.Thread(target=_reader, daemon=True) + reader_thread.start() + reader_thread.join(timeout=(timeout_ms / 1000) + 2) + if not done.is_set(): + proc.kill() + reader_thread.join(1) + elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + return ToolResult( + ok=False, + error=f"timed out after {timeout_ms}ms", + returncode=-signal.SIGKILL, + timed_out=True, + duration_ms=elapsed, + ) + + combined = stdout_b.decode("utf-8", errors="replace") + if stderr_b: + combined += "\n" + stderr_b.decode("utf-8", errors="replace") + elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + return ToolResult( + ok=proc.returncode == 0, + output=combined if proc.returncode == 0 else "", + error=combined if proc.returncode != 0 else "", + returncode=proc.returncode or 0, + duration_ms=elapsed, + ) + except FileNotFoundError: + elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + return ToolResult( + ok=False, + error=f"executable not found: {cmd[0]}", + duration_ms=elapsed, + ) + except Exception as e: + elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000) + return ToolResult(ok=False, error=str(e), duration_ms=elapsed) + + +def _check_tool(name: str) -> bool: + return shutil.which(name) is not None + + +# ── Web tools ────────────────────────────────────────────────────── + +def _run_http(args: dict, timeout_ms: int) -> ToolResult: + url = args.get("url", "") + method = args.get("method", "GET").upper() + headers = args.get("headers", {}) + data = args.get("data", "") + if not url: + return ToolResult(ok=False, error="url is required") + cmd = ["curl", "-s", "-S", "-L", "-X", method] + for k, v in headers.items(): + cmd.extend(["-H", f"{k}: {v}"]) + if data and method in ("POST", "PUT", "PATCH"): + cmd.extend(["-d", data]) + cmd.append(url) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_sqlmap(args: dict, timeout_ms: int) -> ToolResult: + url = args.get("url", "") + if not url: + return ToolResult(ok=False, error="url is required") + cmd = ["sqlmap", "--batch", "--random-agent", "--time-sec", "5"] + if args.get("cookie"): + cmd.extend(["--cookie", args["cookie"]]) + if args.get("data"): + cmd.extend(["--data", args["data"]]) + if args.get("level"): + cmd.extend(["--level", str(args["level"])]) + if args.get("risk"): + cmd.extend(["--risk", str(args["risk"])]) + cmd.append(url) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_nuclei(args: dict, timeout_ms: int) -> ToolResult: + target = args.get("target", "") + if not target: + return ToolResult(ok=False, error="target is required") + cmd = ["nuclei", "-silent", "-no-color"] + if args.get("templates"): + cmd.extend(["-t", args["templates"]]) + if args.get("severity"): + cmd.extend(["-severity", args["severity"]]) + cmd.extend(["-u", target]) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_ffuf(args: dict, timeout_ms: int) -> ToolResult: + url = args.get("url", "") + wordlist = args.get("wordlist", "/usr/share/wordlists/dirb/common.txt") + if not url: + return ToolResult(ok=False, error="url is required") + cmd = ["ffuf", "-u", url, "-w", wordlist, "-ac", "-t", "40"] + if args.get("extensions"): + cmd.extend(["-e", args["extensions"]]) + if args.get("fc"): + cmd.extend(["-fc", str(args["fc"])]) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_dalfox(args: dict, timeout_ms: int) -> ToolResult: + url = args.get("url", "") + if not url: + return ToolResult(ok=False, error="url is required") + cmd = ["dalfox", "url", url, "--silence", "--no-color", "--only-poc", "gfm"] + if args.get("cookie"): + cmd.extend(["--cookie", args["cookie"]]) + if args.get("param"): + cmd.extend(["-p", args["param"]]) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_zap(args: dict, timeout_ms: int) -> ToolResult: + target = args.get("target", "") + if not target: + return ToolResult(ok=False, error="target is required") + cmd = ["zap-cli", "--silent", "quick-scan", "-t", str(args.get("timeout", 60))] + if args.get("spider"): + cmd.append("--spider") + cmd.append(target) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_xss_confirm(args: dict, timeout_ms: int) -> ToolResult: + payloads = args.get("payloads", [ + "", + "\">", + "';alert(1)//", + ]) + url_template = args.get("url", "") + param = args.get("param", "q") + if not url_template: + return ToolResult(ok=False, error="url template with {payload} placeholder is required") + for payload in payloads: + url = url_template.replace("{payload}", payload) + try: + resp = _run_subprocess( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", url], + timeout_ms=timeout_ms // len(payloads), + ) + if resp.ok and resp.output.strip() not in ("404", "400"): + return ToolResult( + ok=True, + output=f"XSS vector confirmed: {payload} returned HTTP {resp.output.strip()}", + ) + except Exception: + continue + return ToolResult(ok=False, error="no XSS vectors confirmed from payload set") + + +# ── Crypto tools ─────────────────────────────────────────────────── + +def _run_z3(args: dict, timeout_ms: int) -> ToolResult: + script = args.get("script", "") + if not script: + return ToolResult(ok=False, error="z3 script (SMT-LIB or Python) is required") + ext = ".smt2" if script.strip().startswith("(") else ".py" + with tempfile.NamedTemporaryFile( + mode="w", suffix=ext, delete=False, prefix="z3_" + ) as f: + f.write(script) + tmp = f.name + try: + if ext == ".py": + cmd = ["python3", tmp] + else: + cmd = ["z3", "-in", tmp] + result = _run_subprocess(cmd, timeout_ms=timeout_ms) + os.unlink(tmp) + return result + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def _run_sage(args: dict, timeout_ms: int) -> ToolResult: + script = args.get("script", "") + if not script: + return ToolResult(ok=False, error="sage script is required") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".sage", delete=False, prefix="sage_" + ) as f: + f.write(script) + tmp = f.name + try: + result = _run_subprocess(["sage", tmp], timeout_ms=timeout_ms) + os.unlink(tmp) + return result + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def _run_rsa_tool(args: dict, timeout_ms: int) -> ToolResult: + n = args.get("n", "") + e = args.get("e", "65537") + if not n: + return ToolResult(ok=False, error="n (modulus) is required") + script = f""" +import sys +try: + from factordb.factordb import FactorDB + n = int("{n}") + f = FactorDB(n) + f.connect() + factors = f.get_factor_list() + if factors: + print("factors:", factors) + else: + print("no factors found from FactorDB") +except ImportError: + print("factordb-python not available, trying z3...") +except Exception as e: + print(f"error: {{e}}") +""" + return _run_subprocess( + ["python3", "-c", script.replace("{", "{{").replace("}", "}}")], + timeout_ms=timeout_ms, + ) + + +def _run_factordb(args: dict, timeout_ms: int) -> ToolResult: + n = args.get("n", "") + if not n: + return ToolResult(ok=False, error="n is required") + script = f""" +import sys, json +try: + from factordb.factordb import FactorDB + f = FactorDB({n}) + f.connect() + factors = f.get_factor_list() + print(json.dumps({{"factors": factors, "status": f.get_status()}})) +except ImportError: + import urllib.request + url = f"http://factordb.com/api?query={n}" + resp = urllib.request.urlopen(url, timeout=10) + print(resp.read().decode()) +except Exception as e: + print(json.dumps({{"error": str(e)}})) +""" + return _run_subprocess(["python3", "-c", script], timeout_ms=timeout_ms) + + +def _run_hashcat(args: dict, timeout_ms: int) -> ToolResult: + hash_value = args.get("hash", "") + mode = args.get("mode", "0") + wordlist = args.get("wordlist", "/usr/share/wordlists/rockyou.txt") + if not hash_value: + return ToolResult(ok=False, error="hash is required") + cmd = ["hashcat", "--force", "-m", mode, "-a", "0", hash_value, wordlist] + if args.get("rules"): + cmd.extend(["-r", args["rules"]]) + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_hashid(args: dict, timeout_ms: int) -> ToolResult: + hash_value = args.get("hash", "") + if not hash_value: + return ToolResult(ok=False, error="hash is required") + cmd = ["hashid", "-m", hash_value] + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_decode(args: dict, timeout_ms: int) -> ToolResult: + encoded = args.get("data", "") + encoding = args.get("encoding", "base64") + if not encoded: + return ToolResult(ok=False, error="data is required") + script = f""" +import base64, binascii, json +data = {json.dumps(encoded)} +enc = {json.dumps(encoding)} +try: + if enc == "base64": + result = base64.b64decode(data).decode("utf-8", errors="replace") + elif enc == "base32": + result = base64.b32decode(data).decode("utf-8", errors="replace") + elif enc == "hex": + result = bytes.fromhex(data).decode("utf-8", errors="replace") + elif enc == "rot13": + import codecs + result = codecs.decode(data, "rot_13") + else: + result = f"unknown encoding: {{enc}}" + print(result) +except Exception as e: + print(f"decode failed: {{e}}") +""" + return _run_subprocess(["python3", "-c", script], timeout_ms=timeout_ms) + + +# ── Reverse-engineering tools ────────────────────────────────────── + +def _run_js_deobf(args: dict, timeout_ms: int) -> ToolResult: + source = args.get("source", "") + if not source: + return ToolResult(ok=False, error="source is required") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".js", delete=False, prefix="jsdeob_" + ) as f: + f.write(source) + tmp = f.name + try: + cmd = ["npx", "--yes", "deobfuscate-js", tmp] + result = _run_subprocess(cmd, timeout_ms=timeout_ms) + os.unlink(tmp) + return result + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def _run_sourcemap(args: dict, timeout_ms: int) -> ToolResult: + url = args.get("url", "") + if not url: + return ToolResult(ok=False, error="url is required") + cmd = ["curl", "-s", "-L", url] + result = _run_subprocess(cmd, timeout_ms=timeout_ms) + if not result.ok: + return result + try: + import base64 + import json + data = json.loads(result.output) + if "mappings" in data: + sources = data.get("sources", []) + return ToolResult(ok=True, output=json.dumps({"sources": sources, "file": data.get("file", "")})) + return ToolResult(ok=False, error="response is not a valid source map") + except json.JSONDecodeError as e: + return ToolResult(ok=False, error=f"invalid JSON: {e}") + + +def _run_wasm_decompile(args: dict, timeout_ms: int) -> ToolResult: + wasm_path = args.get("path", "") + wasm_data = args.get("data", "") + if wasm_path: + cmd = ["wasm-decompile", wasm_path] + elif wasm_data: + with tempfile.NamedTemporaryFile( + mode="wb", suffix=".wasm", delete=False, prefix="wasm_" + ) as f: + import base64 + f.write(base64.b64decode(wasm_data)) + tmp = f.name + try: + cmd = ["wasm-decompile", tmp] + result = _run_subprocess(cmd, timeout_ms=timeout_ms) + os.unlink(tmp) + return result + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + else: + return ToolResult(ok=False, error="path or base64-encoded data is required") + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +# ── Pwn tools ────────────────────────────────────────────────────── + +def _run_triage(args: dict, timeout_ms: int) -> ToolResult: + binary = args.get("binary", "") + if not binary: + return ToolResult(ok=False, error="binary path is required") + cmd = ["file", binary] + file_result = _run_subprocess(cmd, timeout_ms=5000) + checksec_cmd = ["checksec", "--file=" + binary] + check_result = _run_subprocess(checksec_cmd, timeout_ms=5000) + combined = file_result.output or "" + if check_result.output: + combined += "\n" + check_result.output + return ToolResult(ok=True, output=combined) + + +def _run_ropgadget(args: dict, timeout_ms: int) -> ToolResult: + binary = args.get("binary", "") + if not binary: + return ToolResult(ok=False, error="binary path is required") + cmd = ["ROPgadget", "--binary", binary] + if args.get("depth"): + cmd.extend(["--depth", str(args["depth"])]) + if args.get("only"): + cmd.extend(["--only", args["only"]]) + if args.get("range"): + cmd.extend(["--range", args["range"]]) + cmd.append("--silent") + return _run_subprocess(cmd, timeout_ms=timeout_ms) + + +def _run_pwntools(args: dict, timeout_ms: int) -> ToolResult: + script = args.get("script", "") + if not script: + return ToolResult(ok=False, error="pwntools Python script is required") + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, prefix="pwn_" + ) as f: + f.write("#!/usr/bin/env python3\n") + f.write("from pwn import *\n") + f.write("context.log_level = 'error'\n") + f.write(script) + tmp = f.name + try: + result = _run_subprocess(["python3", tmp], timeout_ms=timeout_ms) + os.unlink(tmp) + return result + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def _run_exploit_template(args: dict, timeout_ms: int) -> ToolResult: + target = args.get("target", "") + template_type = args.get("type", "ret2libc") + if not target: + return ToolResult(ok=False, error="target binary path is required") + arch = args.get("arch", "amd64") + template = f"""#!/usr/bin/env python3 +from pwn import * +context.binary = '{target}' +context.arch = '{arch}' +context.log_level = 'warn' + +elf = ELF('{target}') +""" + if template_type == "ret2libc": + template += f""" +# ret2libc template +rop = ROP(elf) +pop_rdi = rop.find_gadget(['pop rdi', 'ret']) +if pop_rdi: + pop_rdi = pop_rdi[0] + print(f"pop rdi; ret @ {{hex(pop_rdi)}}") +bin_sh = next(elf.search(b'/bin/sh'), None) +if bin_sh: + print(f"/bin/sh @ {{hex(bin_sh)}}") +system = elf.plt.get('system') +if system: + print(f"system @ {{hex(system)}}") +else: + libc = elf.libc + if libc: + print(f"libc: {{libc.path}}") +""" + elif template_type == "shellcode": + template += f""" +# shellcode execution template +shellcode = asm(shellcraft.sh()) +print(f"shellcode ({len(shellcode)} bytes): {{shellcode.hex()}}") +""" + else: + template += f""" +# {template_type} exploit template +print(f"Target: {{elf.path}}") +print(f"PIE: {{elf.pie}}") +print(f"NX: {{elf.nx}}") +print(f"Canary: {{elf.canary}}") +""" + return ToolResult(ok=True, output=template) + + +# ── Tool registry ────────────────────────────────────────────────── + +class ToolRegistry: + def __init__(self): + self._tools: dict[str, callable] = { + "http": _run_http, + "sqlmap": _run_sqlmap, + "nuclei": _run_nuclei, + "ffuf": _run_ffuf, + "dalfox": _run_dalfox, + "zap": _run_zap, + "xss_confirm": _run_xss_confirm, + "z3": _run_z3, + "sage": _run_sage, + "rsa": _run_rsa_tool, + "factordb": _run_factordb, + "hashcat": _run_hashcat, + "hashid": _run_hashid, + "decode": _run_decode, + "js_deobfuscate": _run_js_deobf, + "sourcemap": _run_sourcemap, + "wasm_decompile": _run_wasm_decompile, + "triage": _run_triage, + "ropgadget": _run_ropgadget, + "pwntools": _run_pwntools, + "exploit_template": _run_exploit_template, + } + + def list_tools(self) -> list[str]: + return list(self._tools.keys()) + + def has_tool(self, name: str) -> bool: + return name in self._tools + + def run(self, name: str, args: dict, timeout_ms: int = 30_000) -> ToolResult: + if name not in self._tools: + return ToolResult(ok=False, error=f"unknown tool: {name}") + fn = self._tools[name] + return fn(args, timeout_ms) + + def health_check(self) -> dict: + results = {} + for name in self._tools: + binary = _binary_for_tool(name) + if binary: + results[name] = {"available": _check_tool(binary)} + else: + results[name] = {"available": True} # Python-based, assume available + return { + "tools": results, + "available_count": sum(1 for v in results.values() if v["available"]), + "total_count": len(results), + } + + +def _binary_for_tool(name: str) -> Optional[str]: + mapping = { + "http": "curl", + "sqlmap": "sqlmap", + "nuclei": "nuclei", + "ffuf": "ffuf", + "dalfox": "dalfox", + "zap": "zap-cli", + "xss_confirm": "curl", + "z3": "z3", + "sage": "sage", + "hashcat": "hashcat", + "hashid": "hashid", + "pwntools": "python3", + "ropgadget": "ROPgadget", + } + return mapping.get(name) diff --git a/src-misc/classifier-prompt.txt b/src-misc/classifier-prompt.txt new file mode 100644 index 0000000..0a47830 --- /dev/null +++ b/src-misc/classifier-prompt.txt @@ -0,0 +1,17 @@ +You are a tool-call classifier for the Zesdex approval harness. +Given a tool name and its arguments, determine if the operation is safe. + +Classification rules: +- read, grep, glob, search, dir_list: always SAFE +- write, edit: SAFE if path is inside a workspace root; SUSPICIOUS if + path targets system directories (/etc, /bin, /boot, /dev, /sys) +- delete: SAFE if inside workspace and not a critical path; DANGEROUS + if it matches recursive delete patterns outside workspace +- bash: SAFE for read-only commands (ls, cat, head, tail, ps, df); + DANGEROUS for destructive commands (rm -rf, dd, mkfs, >/dev/sdX) +- git_operator: SAFE for status/log/diff/commit; DANGEROUS for + force-push, reset --hard, clean -fdx, branch -D +- web_download: DANGEROUS if target path is outside workspace +- All other tools: SAFE by default + +Output exactly one word: SAFE, SUSPICIOUS, or DANGEROUS. diff --git a/src-misc/quality-reviewer-prompt.txt b/src-misc/quality-reviewer-prompt.txt new file mode 100644 index 0000000..07d853f --- /dev/null +++ b/src-misc/quality-reviewer-prompt.txt @@ -0,0 +1,25 @@ +You are a code quality reviewer for Zesdex. Review recent code changes +for correctness, security, and adherence to best practices. + +You have read-only access to the workspace. Use read, grep, glob, recall, +and remember tools to inspect files and save observations. + +Review guidelines: +1. Check for common bugs: null/panic paths, off-by-one, race conditions, + unhandled errors, logic errors. +2. Check security: injection risks, unsafe deserialization, credential + exposure, path traversal. +3. Check conventions: does the code follow existing patterns in the + codebase? Check surrounding files for naming, structure, style. +4. Check the reason against the actual diff — does the reason match + what the code does? + +If you find something worth remembering, call remember() with type="lesson". +Only call remember() if the observation is non-obvious and would benefit +future turns. Skip trivial style nits. + +Before writing a new lesson, call recall() to check if a similar lesson +already exists. Deduplicate — don't write the same lesson twice. + +Output: a one-line verdict summarizing your review. +Include "N lesson(s)" at the end if you created lessons. diff --git a/src-misc/system-prompt.txt b/src-misc/system-prompt.txt new file mode 100644 index 0000000..2315693 --- /dev/null +++ b/src-misc/system-prompt.txt @@ -0,0 +1,20 @@ +You are Zesdex, an autonomous AI coding and security agent operating in a +terminal-based TUI environment. Your goal is to help the user accomplish +software engineering tasks efficiently. + +Core principles: +1. Be concise but thorough — prefer showing results over describing them. +2. Use the tools available to explore, understand, and modify the codebase. +3. For simple tasks, handle them directly with read/grep/write/edit. +4. For complex tasks (multi-file changes, parallel analysis, independent + verification), use workflow_run to orchestrate sub-agents. +5. Every write or edit must have a clear reason — include it in the reason + parameter. +6. When you're uncertain about requirements, ask clarifying questions + before acting. +7. After making changes, verify they work by running builds or tests. +8. Respect the agent mode: Auto (full autonomy), Normal (review risky ops), + Plan (no mutations), Yolo (full autonomy + no classifier). + +Available tools are described in the system-tools.txt section. Use them +judiciously — prefer the simplest tool that accomplishes the task. diff --git a/src-misc/system-tools.txt b/src-misc/system-tools.txt new file mode 100644 index 0000000..20e2e6c --- /dev/null +++ b/src-misc/system-tools.txt @@ -0,0 +1,45 @@ +You have access to the following tools. Use them to accomplish the user's request. +For simple operations (read, grep, write small edits) use tools directly. +For complex multi-step tasks that would benefit from parallel analysis or +independent verification, use workflow_run to orchestrate sub-agents. + +Core tools: +- read(path) — Read file contents. Use when you need to inspect code. +- grep(pattern, path?) — Search for a pattern in files. +- glob(pattern) — List files matching a glob pattern. +- write(path, content, reason) — Write content to a file. Reason is required. +- edit(path, old, new, reason) — Replace text in a file. Reason is required. +- delete(path) — Delete a file or empty directory. +- bash(command) — Run a shell command. Use for builds, tests, git ops. +- bash_output(job_id) — Poll output of a background bash job. +- bash_kill(job_id) — Kill a background bash job. +- cd(path) — Change working directory. +- dir_list(path) — List directory contents. +- dir_cache_update() — Refresh the directory cache. + +Git tools: +- git_operator(args, confirm_destructive?) — Run git commands. Some destructive + operations (force-push, reset --hard, branch -D) require confirm_destructive=true. +- git_worktree(args) — Manage git worktrees. +- git_cred(operation) — Manage git credentials. + +Web tools: +- web_fetch(url) — Fetch a URL and return markdown content. +- web_search(query) — Search the web for information. +- web_download(url, path) — Download a file (max 500 MiB). + +Memory & Planning: +- remember(text, type?) — Save to memory (type: lesson | reference | feedback). +- recall(query) — Search memory for relevant entries. +- forget(name) — Remove a memory entry. +- plan_enter() — Enter plan mode (for planning before changes). +- plan_ready() — Mark plan as ready for execution. +- seqthink(thought) — Record a chain-of-thought step. + +Workflow: +- workflow_run(script, args) — Fan out work to sub-agents. Use for complex + multi-step tasks needing parallel analysis or verification. Pass inline + scripts with agent(), parallel(), and pipeline() primitives. + +Each write/edit call MUST include a non-empty reason argument explaining +why the change is being made. This is enforced deterministically. diff --git a/src/app/mode/bash.rs b/src/app/mode/bash.rs index d7d118d..30ecb49 100644 --- a/src/app/mode/bash.rs +++ b/src/app/mode/bash.rs @@ -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; -} diff --git a/src/app/mode/editor.rs b/src/app/mode/editor.rs index e635882..bb1a1d1 100644 --- a/src/app/mode/editor.rs +++ b/src/app/mode/editor.rs @@ -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, + pub undo_stack: Vec>, + 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>) -> 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, +} + +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; } diff --git a/src/app/mode/effort.rs b/src/app/mode/effort.rs index ae50eee..afdde85 100644 --- a/src/app/mode/effort.rs +++ b/src/app/mode/effort.rs @@ -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; } diff --git a/src/app/mode/security.rs b/src/app/mode/security.rs index 82d8032..d6fa519 100644 --- a/src/app/mode/security.rs +++ b/src/app/mode/security.rs @@ -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); diff --git a/src/app/mode/settings.rs b/src/app/mode/settings.rs index 90ac124..b7bfcbb 100644 --- a/src/app/mode/settings.rs +++ b/src/app/mode/settings.rs @@ -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; -} diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs index f1bb85b..8c7884b 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -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 { 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 { 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::(&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> { @@ -572,7 +531,6 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result Option { - // Normalize to lower-case words for comparison. let new_words: std::collections::HashSet = 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> { 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, diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 6893997..1432796 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -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: [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::list(&state.memory_dir) .iter() .filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok()) diff --git a/src/app/runtime/mod.rs b/src/app/runtime/mod.rs index 21bcb67..9fb67ca 100644 --- a/src/app/runtime/mod.rs +++ b/src/app/runtime/mod.rs @@ -1,6 +1,4 @@ pub mod actions; pub mod commands; pub mod event_loop; -#[expect(dead_code)] -pub mod shortsend; pub mod stream; diff --git a/src/app/runtime/stream/tools/mod.rs b/src/app/runtime/stream/tools/mod.rs index d169ffb..e69de29 100644 --- a/src/app/runtime/stream/tools/mod.rs +++ b/src/app/runtime/stream/tools/mod.rs @@ -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. diff --git a/src/app/runtime/stream/turn.rs b/src/app/runtime/stream/turn.rs index 0ef6c45..e69de29 100644 --- a/src/app/runtime/stream/turn.rs +++ b/src/app/runtime/stream/turn.rs @@ -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. diff --git a/src/app/sec/daemon.rs b/src/app/sec/daemon.rs index 2586be7..97bed05 100644 --- a/src/app/sec/daemon.rs +++ b/src/app/sec/daemon.rs @@ -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, + 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, - pub running: bool, + child: Option, + child_stdin: Option>>, + response_buf: Arc>>, + running: Arc, + token: String, + next_req_id: Arc>, } 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; + 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 { + 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::(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 { + 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 { + 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 { + 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 { + self.child.as_ref().map(|c| c.id()) + } +} + +impl Drop for SecDaemon { + fn drop(&mut self) { + let _ = self.stop(); + } } pub fn health_check() -> Result { - Ok(true) + let path = crate::security::install::get_sidecar_path(); + Ok(path.exists()) } diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index 73163d6..f818fac 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -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, } 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, } } diff --git a/src/app/workflow/engine.rs b/src/app/workflow/engine.rs index c8c86c9..5557f75 100644 --- a/src/app/workflow/engine.rs +++ b/src/app/workflow/engine.rs @@ -110,29 +110,8 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap) -> 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 { - 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(); - } -} diff --git a/src/main.rs b/src/main.rs index 1a90aab..c8246fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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>, @@ -548,7 +543,6 @@ fn run_loop( result } - fn run_loop_inner( state: &mut app::state::rest::AppStateRest, terminal: &mut Terminal>, diff --git a/src/model/memory.rs b/src/model/memory.rs index 7aaa0ba..67048b0 100644 --- a/src/model/memory.rs +++ b/src/model/memory.rs @@ -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 { 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 } 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> { 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::list(session_dir) .iter() .filter_map(|n| Memory::read(session_dir, n).ok()) diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index 02b3672..e9e686b 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -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; diff --git a/src/tool/mod.rs b/src/tool/mod.rs index f40689b..f728c00 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -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) -> Self { self.graduated_checks = v; self } pub fn build(self) -> ToolCtx { ToolCtx {