diff --git a/Cargo.toml b/Cargo.toml index f04e13f..e336519 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,8 @@ base64 = "0.22" sha2 = "0.10" libc = "0.2" rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } -chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" +chrono = { version = "0.4", features = ["serde"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } webbrowser = "1" diff --git a/README.md b/README.md index 11f9780..7ad4e4d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Zesdex -> Autonomous AI coding and security agent in a terminal-based TUI. +> Autonomous AI coding agent in a terminal-based TUI. -Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in security guardrails at every layer. +Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in guardrails at every layer. --- @@ -21,9 +21,8 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi |----------|-------| | **Filesystem** | `read`, `write`, `edit`, `delete` | | **Search** | `grep` (recursive text), `glob` (file patterns) | -| **Shell** | `bash` (with catastrophic guard), `bash_output`, `bash_kill` | +| **Shell** | `bash`, `bash_output`, `bash_kill` | | **Git** | `git_operator`, `git_worktree`, `git_cred` | -| **Internet** | `fetch` (URL→markdown), `download`, `web_search` | | **Memory** | `remember`, `recall`, `forget` | | **Planning** | `plan_enter`, `plan_ready`, `seqthink` | | **Workflow** | `workflow_run`, `note_finding` | @@ -37,14 +36,8 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi - **MCP Support** — [Model Context Protocol](https://modelcontextprotocol.io/) integration for connecting to external AI tool servers. - **Sequential Thinking** — Chain-of-thought reasoning tool for step-by-step problem decomposition. -### Security -- **Catastrophic Guard** — Detects and blocks destructive operations (`rm -rf`, `force push`, credential exfiltration) across all tool invocations. -- **Graduated Checks** — Content-aware pattern matching for common danger zones (API keys, passwords, git credentials) with configurable rules. -- **Risky Tool Classification** — Write, delete, edit, bash, and git operations are flagged for additional scrutiny. -- **Workspace Isolation** — All file operations are validated against workspace roots. Path traversal outside the workspace is rejected. - **Session Locking** — Prevents multiple processes from operating on the same session directory. -- **Security Sidecar** — Optional Python daemon for deep vulnerability scanning (see below). ### Session Management @@ -54,36 +47,6 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi --- -## Security Sidecar - -An optional Python-based companion daemon that provides security analysis tools beyond what the core Rust binary offers. - -### Available Tools - -| Category | Tools | Required Binary | -|----------|-------|-----------------| -| **Web Security** | `sqlmap`, `nuclei`, `ffuf`, `dalfox`, `zap`, `xss_confirm`, `http` | sqlmap, nuclei, ffuf, dalfox, zap-cli, curl | -| **Cryptography** | `z3`, `sage`, `rsa`, `factordb`, `hashcat`, `hashid`, `decode` | z3, sage, hashcat, hashid | -| **Reverse Engineering** | `js_deobfuscate`, `sourcemap`, `wasm_decompile` | npx, wasm-decompile | -| **Binary Exploitation** | `triage`, `ropgadget`, `pwntools`, `exploit_template` | file, checksec, ROPgadget, python3 | - -### Installation - -```bash -pip install -r security-sidecar/requirements.txt - -# Optional: install full extras for crypto/pwn tools -pip install -r security-sidecar/requirements.txt[full] -``` - -### Health Check - -```bash -python -m zesdex_sec_daemon --health -``` - ---- - ## Architecture ``` @@ -106,11 +69,9 @@ src/ │ ├── harness.rs # Tool harness for agent execution │ ├── workflow/ # Workflow engine (script DSL, executor) │ ├── mcp/ # MCP client manager -│ ├── sec/ # Security sidecar integration │ ├── subagent/ # Sub-agent spawn, context, events │ ├── bgbash/ # Background bash job management │ ├── review/ # Self-review quality system -│ └── catastrophic.rs # Catastrophic operation detection ├── controller/ │ ├── input.rs # Key event → Action mapping │ └── command.rs # Slash command parser @@ -135,20 +96,17 @@ src/ │ ├── msglog/ # Message log (SQLite-backed) │ ├── agent_def/ # Agent definitions (builtin, global, session) │ └── session_lock.rs # Flock-based session locking -├── security/ -│ └── install.rs # Sidecar binary management ├── service/ │ ├── provider.rs # AI provider abstraction │ └── oauth/ # OAuth PKCE flow with loopback server ├── tool/ # 28 tool implementations │ ├── fs/ # read, write, edit, delete │ ├── search.rs # grep, glob -│ ├── shell.rs # bash (with catastrophic guard) +│ ├── shell.rs # bash │ ├── bash_tools.rs # bash_output, bash_kill │ ├── git_operator.rs # git operations │ ├── git_worktree.rs # git worktree management │ ├── git_cred.rs # git credential store/get/erase -│ ├── internet/ # fetch, download, web_search │ ├── memory/ # remember, forget, recall │ ├── plan.rs # plan_enter, plan_ready │ ├── seqthink.rs # Sequential thinking @@ -189,7 +147,6 @@ RUST_LOG=debug zesdex | `Ctrl+Q` | Quit | | `Ctrl+H` | Help overlay | | `Ctrl+P` | Settings overlay | -| `Ctrl+A` | Toggle yolo arm | | `Ctrl+B` | Bash panel | | `Ctrl+S` | Session hub | | `Ctrl+T` | Task list | @@ -223,7 +180,6 @@ All configuration lives in `~/.config/zesdex/` (or platform equivalent via the ` | `app_config.json` | AI provider definitions (name, API base URL, auth type, default model) | | `memory/` | Persistent lesson and reference storage (Markdown with YAML frontmatter) | | `sessions/` | Per-session transcripts, edit logs, and activity data | -| `bin/` | Security sidecar binary | | `run/` | Unix domain sockets for daemon mode | ### Provider Configuration @@ -258,7 +214,6 @@ Key settings in `settings.json`: | Setting | Default | Description | |---------|---------|-------------| -| `internet_mode` | `Off` | `Off`, `ReadOnly`, or `Full` | | `review_enabled` | `true` | Enable self-review after tool execution | | `review_max_lessons_per_run` | `5` | Max lessons loaded per review cycle | | `adaptive_review_max_skip` | `3` | Consecutive passes before skipping review | @@ -273,7 +228,6 @@ Key settings in `settings.json`: ### Prerequisites - **Rust** 2021 edition toolchain ([rustup](https://rustup.rs/)) -- **Python 3** (optional, for the security sidecar) ### Build from Source @@ -284,17 +238,6 @@ cargo build --release ./target/release/zesdex ``` -### Security Sidecar (Optional) - -```bash -pip install -r security-sidecar/requirements.txt -``` - -For full crypto and pwn tool support: - -```bash -pip install pycryptodome factordb-python pwntools ropper -``` --- diff --git a/security-sidecar/requirements.txt b/security-sidecar/requirements.txt deleted file mode 100644 index e8762e1..0000000 --- a/security-sidecar/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index dfcb227..0000000 --- a/security-sidecar/setup.cfg +++ /dev/null @@ -1,29 +0,0 @@ -[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 deleted file mode 100644 index 2af5533..0000000 --- a/security-sidecar/zesdex_sec_daemon/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""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 deleted file mode 100644 index 37651a1..0000000 --- a/security-sidecar/zesdex_sec_daemon/__main__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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 deleted file mode 100644 index d8e9199..0000000 --- a/security-sidecar/zesdex_sec_daemon/installer.py +++ /dev/null @@ -1,241 +0,0 @@ -"""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 deleted file mode 100644 index 9f964b6..0000000 --- a/security-sidecar/zesdex_sec_daemon/protocol.py +++ /dev/null @@ -1,173 +0,0 @@ -"""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 deleted file mode 100644 index 09c7bf0..0000000 --- a/security-sidecar/zesdex_sec_daemon/tools.py +++ /dev/null @@ -1,602 +0,0 @@ -"""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 index 56b4f2d..5a90052 100644 --- a/src-misc/classifier-prompt.txt +++ b/src-misc/classifier-prompt.txt @@ -11,7 +11,6 @@ Classification rules: 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 index 57e4cd9..2105ea7 100644 --- a/src-misc/quality-reviewer-prompt.txt +++ b/src-misc/quality-reviewer-prompt.txt @@ -1,11 +1,10 @@ -You are an overengineering, perfectionist, and diligent programmer who does not prioritize efficiency and does not assume or guess anything, so everything must be based on data. You are acting as a code quality reviewer for Zesdex. Review recent code changes for correctness, security, and adherence to best practices. +You are an overengineering, perfectionist, and diligent programmer who does not prioritize efficiency and does not assume or guess anything, so everything must be based on data. You are acting as a code quality reviewer for Zesdex. Review recent code changes for correctness, 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 correctness and real utility: Ensure the code contains absolutely zero placeholders, stubs, or lazy implementations (e.g., no `todo!()`, `pass`, or incomplete logic). Every code path must be fully implemented, functional, and deterministic. Verify that no dead code or redundant structures are introduced under the guise of efficiency. 2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws. -3. Check security: Look for injection risks, unsafe deserialization, credential exposure, and path traversal vulnerabilities. 4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture. 5. Check intent against diff: Does the actual implementation match what the code is intended to do? diff --git a/src-misc/system-prompt.txt b/src-misc/system-prompt.txt index 5df5c8d..ec4190d 100644 --- a/src-misc/system-prompt.txt +++ b/src-misc/system-prompt.txt @@ -1,4 +1,4 @@ -You are Zesdex, an overengineering, perfectionist, and diligent programmer who does not prioritize efficiency and does not assume or guess anything, so everything must be based on data. You are 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 with absolute correctness and real utility. +You are Zesdex, an overengineering, perfectionist, and diligent programmer who does not prioritize efficiency and does not assume or guess anything, so everything must be based on data. You are an autonomous AI coding agent operating in a terminal-based TUI environment. Your goal is to help the user accomplish software engineering tasks with absolute correctness and real utility. Core principles: 1. Be concise but thorough — prefer showing results over describing them. @@ -11,5 +11,7 @@ Core principles: 8. For greetings or conversation that doesn't require code changes, respond naturally WITHOUT calling any tools. 9. After making changes, verify they work by running builds or tests. +14. TASK MANAGEMENT: Every time the user gives a command, you MUST immediately use the `todowrite` tool to record it as a task. +15. RELENTLESS EXECUTION: Once a task is recorded, you MUST execute it until it is 100% finished. Do not stop calling tools and do not finish your turn prematurely. If you encounter errors, fix them and continue relentlessly until the goal is achieved. Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task. \ No newline at end of file diff --git a/src-misc/system-tools.txt b/src-misc/system-tools.txt index 326ea00..0cca47a 100644 --- a/src-misc/system-tools.txt +++ b/src-misc/system-tools.txt @@ -23,10 +23,6 @@ Git tools: - 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). diff --git a/src/app/catastrophic.rs b/src/app/catastrophic.rs deleted file mode 100644 index ad7f3cb..0000000 --- a/src/app/catastrophic.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::path::Path; - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - #[test] - fn test_allow_safe_git_ops() { - assert!(CatastrophicGuard::check_git_operation("git commit -m 'fix'").is_ok()); - assert!(CatastrophicGuard::check_git_operation("git push origin main").is_ok()); - assert!(CatastrophicGuard::check_git_operation("git pull").is_ok()); - assert!(CatastrophicGuard::check_git_operation("git status").is_ok()); - assert!(CatastrophicGuard::check_git_operation("git log --oneline").is_ok()); - } - - #[test] - fn test_block_force_push() { - assert!(CatastrophicGuard::check_git_operation("git push --force origin main").is_err()); - assert!(CatastrophicGuard::check_git_operation("git push +refs/heads/main").is_err()); - assert!(CatastrophicGuard::check_git_operation("git push origin :main").is_err()); - } - - #[test] - fn test_block_reset_hard() { - assert!(CatastrophicGuard::check_git_operation("git reset --hard HEAD~1").is_err()); - assert!(CatastrophicGuard::check_git_operation("git reset --hard origin/main").is_err()); - } - - #[test] - fn test_block_git_clean() { - assert!(CatastrophicGuard::check_git_operation("git clean -fd").is_err()); - assert!(CatastrophicGuard::check_git_operation("git clean -xdf").is_err()); - } - - #[test] - fn test_block_branch_force_delete() { - assert!(CatastrophicGuard::check_git_operation("git branch -D feature").is_err()); - assert!(CatastrophicGuard::check_git_operation("git branch --delete --force main").is_err()); - } - - #[test] - fn test_block_force_checkout() { - assert!(CatastrophicGuard::check_git_operation("git checkout --force other").is_err()); - } - - #[test] - fn test_block_stash_destructive() { - assert!(CatastrophicGuard::check_git_operation("git stash drop").is_err()); - assert!(CatastrophicGuard::check_git_operation("git stash clear").is_err()); - } - - #[test] - fn test_block_filter_branch() { - assert!(CatastrophicGuard::check_git_operation("git filter-branch --force").is_err()); - } - - #[test] - fn test_block_gc_prune() { - assert!(CatastrophicGuard::check_git_operation("git gc --prune=now").is_err()); - } - - #[test] - fn test_allow_safe_shell() { - assert!(CatastrophicGuard::check_shell_command("ls -la /tmp").is_ok()); - assert!(CatastrophicGuard::check_shell_command("echo hello").is_ok()); - assert!(CatastrophicGuard::check_shell_command("cat /etc/hostname").is_ok()); - assert!(CatastrophicGuard::check_shell_command("cargo build").is_ok()); - } - - #[test] - fn test_block_dd() { - assert!(CatastrophicGuard::check_shell_command("dd if=/dev/zero of=/dev/sda").is_err()); - } - - #[test] - fn test_block_format() { - assert!(CatastrophicGuard::check_shell_command("mkfs.ext4 /dev/sdb1").is_err()); - assert!(CatastrophicGuard::check_shell_command("format /dev/sdc").is_err()); - } - - #[test] - fn test_block_shutdown_reboot() { - assert!(CatastrophicGuard::check_shell_command("shutdown -h now").is_err()); - assert!(CatastrophicGuard::check_shell_command("reboot").is_err()); - assert!(CatastrophicGuard::check_shell_command("poweroff").is_err()); - } - - #[test] - fn test_block_system_directory_delete() { - let p = Path::new("/"); - assert!(CatastrophicGuard::check_delete_path(p, &[]).is_err()); - let p = Path::new("/home"); - assert!(CatastrophicGuard::check_delete_path(p, &[]).is_err()); - } - - #[test] - fn test_credential_pattern_blocked() { - assert!(CatastrophicGuard::check_credential_pattern("cat ~/.ssh/id_rsa").is_err()); - assert!(CatastrophicGuard::check_credential_pattern("cat .git-credentials").is_err()); - assert!(CatastrophicGuard::check_credential_pattern("cat ~/.netrc").is_err()); - } - - #[test] - fn test_credential_pattern_allowed() { - assert!(CatastrophicGuard::check_credential_pattern("cat README.md").is_ok()); - assert!(CatastrophicGuard::check_credential_pattern("ls -la").is_ok()); - } - - #[test] - fn test_download_path_sensitive() { - let sensitive = Path::new("/tmp/id_rsa"); - assert!(CatastrophicGuard::check_download_path(sensitive).is_err()); - let sensitive = Path::new("/tmp/credentials.json"); - assert!(CatastrophicGuard::check_download_path(sensitive).is_err()); - } - - #[test] - fn test_download_path_allowed() { - let safe = Path::new("/tmp/report.pdf"); - assert!(CatastrophicGuard::check_download_path(safe).is_ok()); - } - - #[test] - fn test_check_all_blocks_destructive() { - assert!(CatastrophicGuard::check_all("git push --force origin main", &[]).is_err()); - assert!(CatastrophicGuard::check_all("dd if=/dev/zero of=/dev/sda", &[]).is_err()); - } - - #[test] - fn test_check_all_allows_safe() { - assert!(CatastrophicGuard::check_all("git commit -m 'fix'", &[]).is_ok()); - assert!(CatastrophicGuard::check_all("cargo build", &[]).is_ok()); - } - - #[test] - fn test_delete_outside_workspace() { - let workspace = Path::new("/tmp/test_ws"); - let outside = Path::new("/etc/passwd"); - assert!(CatastrophicGuard::check_delete_path(outside, &[workspace]).is_err()); - } -} - -pub struct CatastrophicGuard; - -impl CatastrophicGuard { - pub fn check_git_operation(_cmd: &str) -> Result<(), String> { - Ok(()) - } - - pub fn check_shell_command(_cmd: &str) -> Result<(), String> { - Ok(()) - } - - pub fn check_delete_path(_path: &Path, _workspace_roots: &[&Path]) -> Result<(), String> { - Ok(()) - } - - pub fn check_credential_pattern(_cmd: &str) -> Result<(), String> { - Ok(()) - } - - pub fn check_download_path(_path: &Path) -> Result<(), String> { - Ok(()) - } - - pub fn check_all(_cmd: &str, _workspace_roots: &[&Path]) -> Result<(), String> { - Ok(()) - } -} diff --git a/src/app/harness.rs b/src/app/harness.rs index 612e32c..161042a 100644 --- a/src/app/harness.rs +++ b/src/app/harness.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone, PartialEq)] pub enum Verdict { Allow, + #[allow(dead_code)] Block(String), } @@ -9,12 +10,10 @@ pub struct Harness; impl Harness { pub fn gate_tool_call( tool_name: &str, - args: &serde_json::Value, - workspace_roots: &[&std::path::Path], + _args: &serde_json::Value, + _workspace_roots: &[&std::path::Path], ) -> Verdict { - if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) { - return Verdict::Block(e); - } + if !crate::tool::tool_is_risky(tool_name) { return Verdict::Allow; } @@ -25,38 +24,7 @@ impl Harness { Verdict::Allow } - fn run_catastrophic_guard( - tool_name: &str, - args: &serde_json::Value, - workspace_roots: &[&std::path::Path], - ) -> Result<(), String> { - use super::catastrophic::CatastrophicGuard; - match tool_name { - "bash" => { - let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or(""); - CatastrophicGuard::check_all(cmd, workspace_roots) - } - "git_operator" => { - let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or(""); - let arg_list: Vec = args - .get("args") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - let cmd = format!("git {} {}", operation, arg_list.join(" ")); - CatastrophicGuard::check_all(&cmd, workspace_roots) - } - "delete" => { - let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); - CatastrophicGuard::check_delete_path(std::path::Path::new(path), workspace_roots) - } - "web_download" | "download" => { - let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); - CatastrophicGuard::check_download_path(std::path::Path::new(path)) - } - _ => Ok(()), - } - } + } impl Default for Harness { @@ -114,30 +82,6 @@ mod tests { assert_eq!(result, Verdict::Allow); } - #[test] - fn test_gate_tool_bash_non_destructive_allowed_in_auto() { - let roots: &[&std::path::Path] = &[]; - let result = Harness::gate_tool_call("bash", &json!({"command": "ls -la"}), roots); - assert_eq!(result, Verdict::Allow); - } - - #[test] - fn test_gate_tool_bash_destructive_blocked() { - let roots: &[&std::path::Path] = &[]; - let result = Harness::gate_tool_call("bash", &json!({"command": "dd if=/dev/zero of=/dev/sda"}), roots); - assert!(matches!(result, Verdict::Block(_))); - } - - #[test] - fn test_gate_tool_git_operator_destructive_blocked() { - let roots: &[&std::path::Path] = &[]; - let result = Harness::gate_tool_call( - "git_operator", - &json!({"operation": "push", "args": ["--force"]}), - roots, - ); - assert!(matches!(result, Verdict::Block(_))); - } #[test] fn test_parse_verdict_json_allow() { diff --git a/src/app/mod.rs b/src/app/mod.rs index 7685247..87f05ae 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,3 @@ -pub mod catastrophic; pub mod harness; pub mod mode; pub mod runtime; diff --git a/src/app/mode/mod.rs b/src/app/mode/mod.rs index 178a54f..df28faf 100644 --- a/src/app/mode/mod.rs +++ b/src/app/mode/mod.rs @@ -8,7 +8,6 @@ pub mod mcp; pub mod quit_confirm; pub mod rewind; -pub mod security; pub mod settings; pub mod todo; @@ -25,7 +24,6 @@ pub enum ModeKind { Editor, Effort, Mcp, - Security, Todo, Rewind, Loading, diff --git a/src/app/mode/security.rs b/src/app/mode/security.rs deleted file mode 100644 index d6fa519..0000000 --- a/src/app/mode/security.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::app::runtime::actions::Action; -use crate::app::state::rest::AppStateRest; - -pub fn toggle_security_arm(state: &mut AppStateRest) { - state.misc.security_armed = !state.misc.security_armed; - 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/review/mod.rs b/src/app/review/mod.rs index d53b1fd..4cf368d 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -265,7 +265,7 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { ctx.system_prompt = format!( "You are a code quality reviewer. Review the recent code changes \ - for correctness, security, and adherence to best practices. \ + for correctness, and adherence to best practices. \ Use read-only tools (read, grep, glob, recall, remember) to \ inspect the session files and provide a concise review verdict. \ Session directory: {:?}\n\n\ diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index ee24555..27b7096 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -9,9 +9,9 @@ use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{Origin, Overlay, Toast, ToastKind}; use crate::dto::chat::message::{ChatMessage, Role}; -const MAX_TOOL_ONLY_TURNS: usize = 6; +const MAX_TOOL_ONLY_TURNS: usize = 1000; -const MAX_AGENT_STEPS: usize = 40; +const MAX_AGENT_STEPS: usize = 1000; #[derive(Debug, Clone)] pub enum Action { @@ -29,7 +29,6 @@ pub enum Action { ScrollDown, OpenOverlay(Overlay), CloseOverlay, - ToggleYoloArm, SystemNote { kind: String, message: String, @@ -82,7 +81,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { ModeKind::Editor => Overlay::Editor, ModeKind::Effort => Overlay::Effort, ModeKind::Mcp => Overlay::Mcp, - ModeKind::Security => Overlay::Security, ModeKind::Todo => Overlay::Todo, ModeKind::Rewind => Overlay::Rewind, ModeKind::Loading => Overlay::Loading, @@ -195,10 +193,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.misc.overlay = Overlay::None; state.dirty = true; } - Action::ToggleYoloArm => { - state.misc.yolo_armed = !state.misc.yolo_armed; - state.dirty = true; - } Action::SystemNote { kind: _kind, message } => { let toast = crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Info, @@ -360,6 +354,15 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { } } state.push_toast(Toast::new(ToastKind::Info, message)); + } else if kind == "task_retry" { + state.push_transcript(ChatMessageDisplay::new( + crate::dto::chat::message::Role::System, + message.clone(), + )); + state.push_toast(Toast::new(ToastKind::Info, "Auto-continuing unfinished tasks...".to_string())); + if let Some(ref mut rt) = state.session_runtime { + rt.push_message(crate::dto::chat::message::ChatMessage::system(message.clone())); + } } else { state.push_toast(Toast::new(ToastKind::Info, message)); } @@ -689,10 +692,29 @@ fn run_agent_turn( } return Ok(()); } - let (msg, usage_fb) = tc.client.chat_with_tools_non_streaming( - &wire_msgs, Some(tc.tdefs.clone()), - )?; - (msg, usage_fb) + match tc.client.chat_with_tools_non_streaming(&wire_msgs, Some(tc.tdefs.clone())) { + Ok((msg, usage_fb)) => (msg, usage_fb), + Err(api_err) => { + let todo_path = tc.ctx.session_dir.join("todo.md"); + let mut has_unfinished = false; + if let Ok(todo_text) = std::fs::read_to_string(&todo_path) { + if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) { + has_unfinished = true; + } + } + if has_unfinished { + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::SystemNote { + kind: "task_retry".to_string(), + message: format!("Network/API error: {}. Auto-retrying to finish tasks...", api_err), + }); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + return Err(api_err); + } + } } }; @@ -777,12 +799,35 @@ fn run_agent_turn( archive_message(&tc.db, &tc.session_id, &response); if let Ok(mut q) = events_q.lock() { if stream_started { - q.push_back(TurnEvent::StreamDone(response)); + q.push_back(TurnEvent::StreamDone(response.clone())); } else { - q.push_back(TurnEvent::AssistantMessage(response)); + q.push_back(TurnEvent::AssistantMessage(response.clone())); } } } + + let todo_path = tc.ctx.session_dir.join("todo.md"); + let mut has_unfinished = false; + if let Ok(todo_text) = std::fs::read_to_string(&todo_path) { + if todo_text.lines().any(|l| l.trim_start().starts_with("- [ ]")) { + has_unfinished = true; + } + } + + if has_unfinished { + let sys_text = "You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished."; + let msg = ChatMessage::system(sys_text); + archive_message(&tc.db, &tc.session_id, &msg); + msgs.push(msg); + if let Ok(mut q) = events_q.lock() { + q.push_back(TurnEvent::SystemNote { + kind: "task_retry".to_string(), + message: sys_text.to_string(), + }); + } + continue; + } + break; } } @@ -957,9 +1002,9 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result { let state_token = format!("{:x}", sha2::Sha256::digest(rand_bytes(16))); let mut manager = OAuthManager::new(config.clone()); - let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str()); + let _auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str()); - let _ = webbrowser::open(&auth_url); + // let _ = webbrowser::open(&auth_url); let code = server.wait_for_code(120_000)?; diff --git a/src/app/sec/daemon.rs b/src/app/sec/daemon.rs deleted file mode 100644 index 97bed05..0000000 --- a/src/app/sec/daemon.rs +++ /dev/null @@ -1,228 +0,0 @@ -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 { - child: Option, - child_stdin: Option>>, - response_buf: Arc>>, - running: Arc, - token: String, - next_req_id: Arc>, -} - -impl SecDaemon { - pub fn new() -> Self { - SecDaemon { - 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<()> { - 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.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 { - let path = crate::security::install::get_sidecar_path(); - Ok(path.exists()) -} diff --git a/src/app/sec/mod.rs b/src/app/sec/mod.rs deleted file mode 100644 index d6bbb01..0000000 --- a/src/app/sec/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod daemon; diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index c896dec..7ae6eba 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -227,9 +227,6 @@ impl InputState { pub struct MiscState { pub overlay: Overlay, pub toasts: Vec, - pub yolo_armed: bool, - pub security_armed: bool, - pub esc_press_count: u32, pub last_staleness_sweep_ms: i64, pub thinking: bool, pub effort_level: usize, @@ -244,9 +241,6 @@ impl MiscState { MiscState { overlay: Overlay::None, toasts: Vec::new(), - yolo_armed: false, - security_armed: false, - esc_press_count: 0, last_staleness_sweep_ms: 0, thinking: false, effort_level: 1, diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index c963fb4..370a0ad 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -137,7 +137,6 @@ impl AppStateRest { _download_dir: self.download_dir.clone(), worktrees_dir: self.worktrees_dir.clone(), dir_cache: self.dir_cache.clone(), - internet_mode: self.settings.internet_mode.clone(), origin, graduated_checks: Vec::new(), } diff --git a/src/app/state/types.rs b/src/app/state/types.rs index c21de8c..a89d99f 100644 --- a/src/app/state/types.rs +++ b/src/app/state/types.rs @@ -46,7 +46,6 @@ pub enum Overlay { Editor, Effort, Mcp, - Security, Todo, Rewind, Learning, diff --git a/src/controller/input.rs b/src/controller/input.rs index f2d9c74..69a8d96 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -208,10 +208,6 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { mode::todo::handle_todo_toggle(state); Vec::new() } - Overlay::Security => { - mode::security::handle_security_action(state, &Action::ToggleYoloArm); - Vec::new() - } Overlay::QuitConfirm => { vec![mode::quit_confirm::handle_quit_confirm(true)] } diff --git a/src/main.rs b/src/main.rs index 7947233..e2f3bbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -213,7 +213,6 @@ fn apply_client_update( Some("Editor") => Overlay::Editor, Some("Effort") => Overlay::Effort, Some("Mcp") => Overlay::Mcp, - Some("Security") => Overlay::Security, Some("Todo") => Overlay::Todo, Some("Rewind") => Overlay::Rewind, Some("Learning") => Overlay::Learning, diff --git a/src/model/agent_def/builtin.rs b/src/model/agent_def/builtin.rs index 959f5fe..b99609b 100644 --- a/src/model/agent_def/builtin.rs +++ b/src/model/agent_def/builtin.rs @@ -45,7 +45,6 @@ pub fn builtin_agents() -> Vec { "grep".to_string(), "glob".to_string(), "search".to_string(), - "web_fetch".to_string(), ] ).with_max_steps(15), diff --git a/src/model/settings.rs b/src/model/settings.rs index edb8e25..0efcf97 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -9,19 +9,6 @@ pub enum InternetMode { Full, } -impl InternetMode { - pub fn can_fetch(&self) -> bool { - true - } - - pub fn can_download(&self) -> bool { - true - } - - pub fn can_search(&self) -> bool { - true - } -} #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/security/install.rs b/src/security/install.rs deleted file mode 100644 index eb18928..0000000 --- a/src/security/install.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::path::Path; -use anyhow::Result; - -pub fn install_security_sidecar(target_dir: &Path) -> Result<()> { - let bin_path = target_dir.join("zesdex-security-daemon"); - let current_exe = std::env::current_exe()?; - std::fs::create_dir_all(target_dir)?; - std::fs::copy(¤t_exe, &bin_path)?; - Ok(()) -} - -pub fn verify_sidecar(path: &Path) -> Result { - if !path.exists() { - return Ok(false); - } - let metadata = std::fs::metadata(path)?; - Ok(metadata.is_file()) -} - -pub fn remove_sidecar(path: &Path) -> Result<()> { - if path.exists() { - std::fs::remove_file(path)?; - } - Ok(()) -} - -pub fn get_sidecar_path() -> std::path::PathBuf { - let store = crate::model::store::Store::new(); - store.base_dir.join("bin").join("zesdex-security-daemon") -} diff --git a/src/security/mod.rs b/src/security/mod.rs deleted file mode 100644 index e5b1d65..0000000 --- a/src/security/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod install; diff --git a/src/tool/git_operator.rs b/src/tool/git_operator.rs index f4cb9a1..373cf36 100644 --- a/src/tool/git_operator.rs +++ b/src/tool/git_operator.rs @@ -12,7 +12,7 @@ impl Tool for GitOperator { } fn description(&self) -> &'static str { - "Execute git operations with catastrophic guard protection" + "Execute git operations" } fn parameters(&self) -> Value { @@ -46,10 +46,6 @@ impl Tool for GitOperator { .collect() }) .ok_or_else(|| anyhow!("missing required argument: args"))?; - let full_cmd_str = format!("git {} {}", operation, arg_list.join(" ")); - let workspace_roots: Vec<&std::path::Path> = _ctx.workspaces.iter().map(|p| p.as_path()).collect(); - crate::app::catastrophic::CatastrophicGuard::check_all(&full_cmd_str, &workspace_roots) - .map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?; let output = Command::new("git") .arg(&operation) .args(&arg_list) diff --git a/src/tool/internet/download.rs b/src/tool/internet/download.rs deleted file mode 100644 index 5f2941c..0000000 --- a/src/tool/internet/download.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::fs; -use std::io::copy; -use std::path::PathBuf; -use std::time::Duration; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use super::super::Tool; -use super::super::ToolCtx; -use super::super::resolve_path; - -pub struct Download; - -impl Tool for Download { - fn name(&self) -> &'static str { - "download" - } - - fn description(&self) -> &'static str { - "Download a file from a URL to a local path" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL to download from" - }, - "path": { - "type": "string", - "description": "Local path to save the file (relative to workspace root)" - } - }, - "required": ["url", "path"] - }) - } - - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - if !ctx.internet_mode.can_download() { - anyhow::bail!("download requires internet mode Full, current mode: {:?}", ctx.internet_mode); - } - let url = args.get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: url"))? - .to_string(); - let rel = args.get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))? - .to_string(); - let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; - crate::app::catastrophic::CatastrophicGuard::check_download_path(&path) - .map_err(|e| anyhow!("download blocked: {}", e))?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| anyhow!("failed to create parent directories: {}", e))?; - } - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(120)) - .user_agent("ZedSex/1.0") - .build() - .map_err(|e| anyhow!("failed to create HTTP client: {}", e))?; - let response = client.get(&url) - .send() - .map_err(|e| anyhow!("failed to download '{}': {}", url, e))?; - let status = response.status(); - if !status.is_success() { - anyhow::bail!("download '{}' returned HTTP {}", url, status.as_u16()); - } - let total: u64 = response.content_length().unwrap_or(0); - let mut file = fs::File::create(&path) - .map_err(|e| anyhow!("failed to create file '{}': {}", rel, e))?; - let mut content = response; - let written = copy(&mut content, &mut file) - .map_err(|e| anyhow!("failed to write to '{}': {}", rel, e))?; - Ok(format!("downloaded {} of {} bytes to {}", written, total, rel)) - } -} diff --git a/src/tool/internet/fetch.rs b/src/tool/internet/fetch.rs deleted file mode 100644 index 561294d..0000000 --- a/src/tool/internet/fetch.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::time::Duration; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use super::super::Tool; -use super::super::ToolCtx; - -pub struct Fetch; - -impl Tool for Fetch { - fn name(&self) -> &'static str { - "fetch" - } - - fn description(&self) -> &'static str { - "Fetch a URL and convert the HTML content to markdown" - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL to fetch" - } - }, - "required": ["url"] - }) - } - - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - if !ctx.internet_mode.can_fetch() { - anyhow::bail!("fetch requires internet mode Full, current mode: {:?}", ctx.internet_mode); - } - let url = args.get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: url"))? - .to_string(); - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(30)) - .user_agent("ZedSex/1.0") - .build() - .map_err(|e| anyhow!("failed to create HTTP client: {}", e))?; - let response = client.get(&url) - .send() - .map_err(|e| anyhow!("failed to fetch '{}': {}", url, e))?; - let status = response.status(); - if !status.is_success() { - anyhow::bail!("fetch '{}' returned HTTP {}", url, status.as_u16()); - } - let content_type = response.headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body = response.text() - .map_err(|e| anyhow!("failed to read response body: {}", e))?; - if content_type.contains("text/html") || content_type.contains("application/xhtml") || content_type.is_empty() { - let markdown = html_to_markdown(&body)?; - Ok(markdown) - } else { - let preview = body.chars().take(2000).collect::(); - Ok(format!("Content-Type: {}\n\n{}", content_type, preview)) - } - } -} - -pub(crate) fn html_to_markdown(html: &str) -> Result { - let frag = scraper::Html::parse_document(html); - let sel = scraper::Selector::parse("body") - .map_err(|e| anyhow!("failed to parse selector: {}", e))?; - let body = frag.select(&sel).next() - .map(|e| e.inner_html()) - .unwrap_or_else(|| html.to_string()); - let text = scraper::Html::parse_fragment(&body); - let result: String = text.root_element().text().collect::>().join("\n"); - Ok(result) -} diff --git a/src/tool/internet/mod.rs b/src/tool/internet/mod.rs deleted file mode 100644 index ac6c639..0000000 --- a/src/tool/internet/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod download; -pub mod fetch; -pub mod search; diff --git a/src/tool/internet/search.rs b/src/tool/internet/search.rs deleted file mode 100644 index 4a0ebbd..0000000 --- a/src/tool/internet/search.rs +++ /dev/null @@ -1,242 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use anyhow::{Result, anyhow}; -use super::super::Tool; -use super::super::ToolCtx; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum SearchProvider { - Tavily, - Brave, - SerpApi, - Google, -} - -impl SearchProvider { - pub fn from_str(s: &str) -> Option { - match s.to_lowercase().as_str() { - "tavily" => Some(SearchProvider::Tavily), - "brave" => Some(SearchProvider::Brave), - "serpapi" | "serp_api" => Some(SearchProvider::SerpApi), - "google" => Some(SearchProvider::Google), - _ => None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub title: String, - pub url: String, - pub snippet: String, -} - -pub struct Search; - -impl Tool for Search { - fn name(&self) -> &'static str { - "web_search" - } - - fn description(&self) -> &'static str { - "Search the web for information using a configured search provider (Tavily, Brave, SerpAPI, or Google)." - } - - fn parameters(&self) -> Value { - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query" - }, - "num_results": { - "type": "integer", - "description": "Number of results to return (default: 5)", - "default": 5 - } - }, - "required": ["query"] - }) - } - - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - if !ctx.internet_mode.can_search() { - anyhow::bail!("web_search requires internet mode Full, current mode: {:?}", ctx.internet_mode); - } - let query = args.get("query") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: query"))? - .to_string(); - let num_results = args.get("num_results") - .and_then(|v| v.as_u64()) - .unwrap_or(5) as usize; - - let provider = detect_search_provider(); - match provider { - Some(p) => search_with_provider(&p, &query, num_results), - None => Ok(format!( - "No search provider configured for query '{}'.\n\ - Set ZESDEX_SEARCH_PROVIDER and corresponding API key env vars.\n\ - Supported: tavily (ZESDEX_TAVILY_API_KEY), \ - brave (ZESDEX_BRAVE_API_KEY), \ - serpapi (ZESDEX_SERPAPI_KEY), \ - google (ZESDEX_GOOGLE_API_KEY).", - query - )), - } - } -} - -fn detect_search_provider() -> Option { - if std::env::var("ZESDEX_SEARCH_PROVIDER").ok().is_some() { - let provider_str = std::env::var("ZESDEX_SEARCH_PROVIDER").unwrap_or_default(); - if let Some(p) = SearchProvider::from_str(&provider_str) { - return Some(p); - } - } - if std::env::var("ZESDEX_TAVILY_API_KEY").ok().filter(|k| !k.is_empty()).is_some() { - return Some(SearchProvider::Tavily); - } - if std::env::var("ZESDEX_BRAVE_API_KEY").ok().filter(|k| !k.is_empty()).is_some() { - return Some(SearchProvider::Brave); - } - if std::env::var("ZESDEX_SERPAPI_KEY").ok().filter(|k| !k.is_empty()).is_some() { - return Some(SearchProvider::SerpApi); - } - if std::env::var("ZESDEX_GOOGLE_API_KEY").ok().filter(|k| !k.is_empty()).is_some() { - return Some(SearchProvider::Google); - } - None -} - -fn search_with_provider(provider: &SearchProvider, query: &str, num_results: usize) -> Result { - let results = match provider { - SearchProvider::Tavily => search_tavily(query, num_results)?, - SearchProvider::Brave => search_brave(query, num_results)?, - SearchProvider::SerpApi => search_serpapi(query, num_results)?, - SearchProvider::Google => search_google(query, num_results)?, - }; - if results.is_empty() { - return Ok(format!("No results found for '{}'.", query)); - } - let mut output = format!("Search results for '{}':\n\n", query); - for (i, r) in results.iter().enumerate() { - output.push_str(&format!("{}. {}\n {}\n {}\n\n", i + 1, r.title, r.url, r.snippet)); - } - Ok(output) -} - -fn search_tavily(query: &str, num_results: usize) -> Result> { - let api_key = std::env::var("ZESDEX_TAVILY_API_KEY") - .map_err(|_| anyhow!("ZESDEX_TAVILY_API_KEY not set"))?; - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build()?; - let body = json!({ - "api_key": api_key, - "query": query, - "max_results": num_results, - "include_answer": false, - "search_depth": "basic", - }); - let resp = client.post("https://api.tavily.com/search") - .header("Content-Type", "application/json") - .json(&body) - .send()?; - if !resp.status().is_success() { - anyhow::bail!("Tavily API error: {}", resp.status()); - } - let data: Value = resp.json()?; - let results = data["results"].as_array().cloned().unwrap_or_default(); - Ok(results.iter().filter_map(|r| { - Some(SearchResult { - title: r["title"].as_str()?.to_string(), - url: r["url"].as_str()?.to_string(), - snippet: r["content"].as_str().unwrap_or("").to_string(), - }) - }).collect()) -} - -fn search_brave(query: &str, num_results: usize) -> Result> { - let api_key = std::env::var("ZESDEX_BRAVE_API_KEY") - .map_err(|_| anyhow!("ZESDEX_BRAVE_API_KEY not set"))?; - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build()?; - let resp = client.get("https://api.search.brave.com/res/v1/web/search") - .header("Accept", "application/json") - .header("Accept-Encoding", "gzip") - .header("X-Subscription-Token", &api_key) - .query(&[("q", query), ("count", &num_results.to_string())]) - .send()?; - if !resp.status().is_success() { - anyhow::bail!("Brave API error: {}", resp.status()); - } - let data: Value = resp.json()?; - let results = data["web"]["results"].as_array().cloned().unwrap_or_default(); - Ok(results.iter().filter_map(|r| { - Some(SearchResult { - title: r["title"].as_str()?.to_string(), - url: r["url"].as_str()?.to_string(), - snippet: r["description"].as_str().unwrap_or("").to_string(), - }) - }).collect()) -} - -fn search_serpapi(query: &str, num_results: usize) -> Result> { - let api_key = std::env::var("ZESDEX_SERPAPI_KEY") - .map_err(|_| anyhow!("ZESDEX_SERPAPI_KEY not set"))?; - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build()?; - let resp = client.get("https://serpapi.com/search.json") - .query(&[ - ("q", query), - ("api_key", &api_key), - ("engine", "google"), - ("num", &num_results.to_string()), - ]) - .send()?; - if !resp.status().is_success() { - anyhow::bail!("SerpAPI error: {}", resp.status()); - } - let data: Value = resp.json()?; - let results = data["organic_results"].as_array().cloned().unwrap_or_default(); - Ok(results.iter().filter_map(|r| { - let title = r.get("title")?.as_str()?.to_string(); - let url = r.get("link")?.as_str()?.to_string(); - let snippet = r.get("snippet").and_then(|s| s.as_str()).unwrap_or("").to_string(); - Some(SearchResult { title, url, snippet }) - }).collect()) -} - -fn search_google(query: &str, num_results: usize) -> Result> { - let api_key = std::env::var("ZESDEX_GOOGLE_API_KEY") - .map_err(|_| anyhow!("ZESDEX_GOOGLE_API_KEY not set"))?; - let cx = std::env::var("ZESDEX_GOOGLE_CX") - .map_err(|_| anyhow!("ZESDEX_GOOGLE_CX (Custom Search Engine ID) not set"))?; - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build()?; - let resp = client.get("https://www.googleapis.com/customsearch/v1") - .query(&[ - ("q", query), - ("key", &api_key), - ("cx", &cx), - ("num", &num_results.min(10).to_string()), - ]) - .send()?; - if !resp.status().is_success() { - anyhow::bail!("Google Custom Search API error: {}", resp.status()); - } - let data: Value = resp.json()?; - let results = data["items"].as_array().cloned().unwrap_or_default(); - Ok(results.iter().filter_map(|r| { - let title = r.get("title")?.as_str()?.to_string(); - let url = r.get("link")?.as_str()?.to_string(); - let snippet = r.get("snippet").and_then(|s| s.as_str()).unwrap_or("").to_string(); - Some(SearchResult { title, url, snippet }) - }).collect()) -} diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 3cb88b7..3530e24 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -7,7 +7,6 @@ pub mod fs; pub mod git_cred; pub mod git_operator; pub mod git_worktree; -pub mod internet; pub mod memory; pub mod plan; pub mod search; @@ -39,7 +38,6 @@ pub struct ToolCtx { pub _download_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: std::sync::Arc>, - pub internet_mode: super::model::settings::InternetMode, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, } @@ -67,7 +65,6 @@ pub struct ToolCtxBuilder { pub download_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: std::sync::Arc>, - pub internet_mode: super::model::settings::InternetMode, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, } @@ -81,7 +78,6 @@ impl Default for ToolCtxBuilder { download_dir: PathBuf::new(), worktrees_dir: PathBuf::new(), dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())), - internet_mode: super::model::settings::InternetMode::Off, origin: crate::app::state::types::Origin::Main, graduated_checks: Vec::new(), } @@ -99,7 +95,6 @@ impl ToolCtxBuilder { _download_dir: self.download_dir, worktrees_dir: self.worktrees_dir, dir_cache: self.dir_cache, - internet_mode: self.internet_mode, origin: self.origin, graduated_checks: self.graduated_checks, } @@ -125,9 +120,6 @@ pub fn all_tools() -> Vec> { Box::new(super::tool::plan::PlanReady), Box::new(super::tool::workflow::WorkflowRun), Box::new(super::tool::workflow::NoteFinding), - Box::new(super::tool::internet::fetch::Fetch), - Box::new(super::tool::internet::download::Download), - Box::new(super::tool::internet::search::Search), Box::new(super::tool::memory::remember::Remember), Box::new(super::tool::memory::forget::Forget), Box::new(super::tool::memory::recall::Recall), diff --git a/src/tool/shell.rs b/src/tool/shell.rs index 5db5aee..843750f 100644 --- a/src/tool/shell.rs +++ b/src/tool/shell.rs @@ -13,7 +13,7 @@ impl Tool for Bash { } fn description(&self) -> &'static str { - "Execute a shell command via bash -c with catastrophic guard protection" + "Execute a shell command via bash -c" } fn parameters(&self) -> Value { @@ -41,16 +41,13 @@ impl Tool for Bash { }) } - fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let cmd = args.get("command") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("missing required argument: command"))? .to_string(); let _description = args.get("description").and_then(|v| v.as_str()).unwrap_or(""); let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120000).min(600000); - let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect(); - crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots) - .map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?; super::shell_filter::credentials::check_credential_read(&cmd) .map_err(|e| anyhow!("blocked: {}", e))?; super::shell_filter::git::check_git_destructive(&cmd) diff --git a/src/view/mod.rs b/src/view/mod.rs index a586ff0..a255cfd 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -235,48 +235,6 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, overlay_area); } - crate::app::state::types::Overlay::Security => { - let block = block - .title(" Security ") - .border_style(Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::WARNING })); - let lines = vec![ - Line::from(Span::styled( - if state.misc.security_armed { - "SECURITY ESCALATION ACTIVE" - } else { - "Security Status: Normal" - }, - Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::INFO }) - .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - "", - Style::default(), - )), - Line::from(Span::styled( - format!("Armed: {}", state.misc.security_armed), - Style::default().fg(if state.misc.security_armed { Theme::WARNING } else { Theme::DIM }), - )), - Line::from(Span::styled( - format!("Esc presses: {}", state.misc.esc_press_count), - Style::default().fg(Theme::DIM), - )), - Line::from(Span::styled( - "Catastrophic-op guard: active in all modes", - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - "Credential exfil patterns: monitored", - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - "Destructive git ops: blocked", - Style::default().fg(Theme::TEXT), - )), - ]; - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, overlay_area); - } crate::app::state::types::Overlay::Todo => { let block = block.title(" Tasks "); let msg_count = state.transcript_cache.messages.len();