Refactor: Remove security module and related functionality

- Deleted the `security` module and its associated files, including `daemon.rs` and `install.rs`.
- Removed references to security features in various modules, including `mod.rs`, `mode/mod.rs`, and `input.rs`.
- Updated the `MiscState` struct to eliminate security-related fields.
- Adjusted the `apply_action` function to remove security action handling.
- Increased the maximum limits for tool-only turns and agent steps in `actions/mod.rs`.
- Modified the review prompt to exclude security checks.
- Cleaned up the `git_operator` and `shell` tools to remove catastrophic guard checks.
- Removed internet-related tools and their references from the tool module.
This commit is contained in:
asepharyana
2026-07-12 03:56:43 +07:00
parent 4bfbe1d1b9
commit a974118b5a
39 changed files with 79 additions and 2175 deletions
+1 -1
View File
@@ -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"
+4 -61
View File
@@ -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
```
---
-6
View File
@@ -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
-29
View File
@@ -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
@@ -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",
]
@@ -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()
@@ -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"}
@@ -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": "<req_id>", "op": "call"|"health"|"install", "tool": "<name>", "args": {...}, "timeout": <ms>}
Response: {"id": "<req_id>", "ok": true, "output": "..."} | {"id": "<req_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()
-602
View File
@@ -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", [
"<script>alert(1)</script>",
"\"><script>alert(1)</script>",
"';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)
-1
View File
@@ -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.
+1 -2
View File
@@ -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?
+3 -1
View File
@@ -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.
-4
View File
@@ -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).
-170
View File
@@ -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(())
}
}
+5 -61
View File
@@ -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<String> = 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() {
-1
View File
@@ -1,4 +1,3 @@
pub mod catastrophic;
pub mod harness;
pub mod mode;
pub mod runtime;
-2
View File
@@ -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,
-13
View File
@@ -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);
}
}
+1 -1
View File
@@ -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\
+61 -16
View File
@@ -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<String> {
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)?;
-228
View File
@@ -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<String, ToolHealth>,
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>,
child_stdin: Option<Mutex<Box<dyn Write + Send>>>,
response_buf: Arc<Mutex<Vec<String>>>,
running: Arc<AtomicBool>,
token: String,
next_req_id: Arc<Mutex<u64>>,
}
impl SecDaemon {
pub fn new() -> Self {
SecDaemon {
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<dyn Write + Send>;
let handshake = serde_json::json!({"op": "handshake", "token": self.token});
writeln!(stdin, "{}", handshake).context("handshake write failed")?;
stdin.flush()?;
self.child_stdin = Some(Mutex::new(stdin));
}
self.running.store(true, Ordering::SeqCst);
self.child = Some(child);
Ok(())
}
pub fn stop(&mut self) -> Result<()> {
self.running.store(false, Ordering::SeqCst);
self.child_stdin = None;
if let Some(mut child) = self.child.take() {
child.kill().ok();
child.wait().ok();
}
Ok(())
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
fn next_id(&self) -> String {
let mut id = self.next_req_id.lock().unwrap();
*id += 1;
format!("sec-{}", id)
}
fn do_call(&self, request: Value, timeout_ms: u64) -> Result<SecResponse> {
if !self.running.load(Ordering::SeqCst) {
return Err(anyhow!("security daemon is not running"));
}
let req_id = request.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing id in request"))?
.to_string();
let stdin_lock = self.child_stdin.as_ref()
.ok_or_else(|| anyhow!("stdin not available"))?;
let mut stdin = stdin_lock.lock().map_err(|_| anyhow!("stdin lock"))?;
writeln!(stdin, "{}", serde_json::to_string(&request)?)
.context("write request")?;
stdin.flush()?;
drop(stdin);
let start = Instant::now();
let buf = self.response_buf.clone();
loop {
if start.elapsed().as_millis() as u64 > timeout_ms {
return Err(anyhow!("call timed out after {}ms", timeout_ms));
}
{
let mut buf_lock = buf.lock().map_err(|_| anyhow!("buf lock"))?;
if let Some(pos) = buf_lock.iter().position(|l| {
serde_json::from_str::<SecResponse>(l)
.ok()
.map(|r| r.id == req_id)
.unwrap_or(false)
}) {
let line = buf_lock.remove(pos);
return serde_json::from_str(&line)
.map_err(|e| anyhow!("parse response: {}", e));
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
pub fn call(&self, tool: &str, args: Value, timeout_ms: u64) -> Result<String> {
let request = serde_json::json!({
"id": self.next_id(),
"op": "call",
"tool": tool,
"args": args,
"timeout": timeout_ms,
});
let resp = self.do_call(request, timeout_ms)?;
if resp.ok {
Ok(resp.output)
} else {
Err(anyhow!("{}", resp.error))
}
}
pub fn health_check(&self) -> Result<HealthResult> {
let request = serde_json::json!({
"id": self.next_id(),
"op": "health",
});
let resp = self.do_call(request, 10_000)?;
if resp.ok {
serde_json::from_str(&resp.output)
.map_err(|e| anyhow!("parse health: {}", e))
} else {
Err(anyhow!("health check failed: {}", resp.error))
}
}
pub fn install_tool(&self, tool_name: &str) -> Result<String> {
let request = serde_json::json!({
"id": self.next_id(),
"op": "install",
"tool": tool_name,
"timeout": 120_000,
});
let resp = self.do_call(request, 120_000)?;
if resp.ok {
Ok(resp.output)
} else {
Err(anyhow!("install failed: {}", resp.error))
}
}
pub fn pid(&self) -> Option<u32> {
self.child.as_ref().map(|c| c.id())
}
}
impl Drop for SecDaemon {
fn drop(&mut self) {
let _ = self.stop();
}
}
pub fn health_check() -> Result<bool> {
let path = crate::security::install::get_sidecar_path();
Ok(path.exists())
}
-1
View File
@@ -1 +0,0 @@
pub mod daemon;
-6
View File
@@ -227,9 +227,6 @@ impl InputState {
pub struct MiscState {
pub overlay: Overlay,
pub toasts: Vec<super::types::Toast>,
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,
-1
View File
@@ -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(),
}
-1
View File
@@ -46,7 +46,6 @@ pub enum Overlay {
Editor,
Effort,
Mcp,
Security,
Todo,
Rewind,
Learning,
-4
View File
@@ -208,10 +208,6 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
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)]
}
-1
View File
@@ -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,
-1
View File
@@ -45,7 +45,6 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
"grep".to_string(),
"glob".to_string(),
"search".to_string(),
"web_fetch".to_string(),
]
).with_max_steps(15),
-13
View File
@@ -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)]
-30
View File
@@ -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(&current_exe, &bin_path)?;
Ok(())
}
pub fn verify_sidecar(path: &Path) -> Result<bool> {
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")
}
-1
View File
@@ -1 +0,0 @@
pub mod install;
+1 -5
View File
@@ -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)
-78
View File
@@ -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<String> {
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))
}
}
-78
View File
@@ -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<String> {
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::<String>();
Ok(format!("Content-Type: {}\n\n{}", content_type, preview))
}
}
}
pub(crate) fn html_to_markdown(html: &str) -> Result<String> {
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::<Vec<_>>().join("\n");
Ok(result)
}
-3
View File
@@ -1,3 +0,0 @@
pub mod download;
pub mod fetch;
pub mod search;
-242
View File
@@ -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<Self> {
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<String> {
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<SearchProvider> {
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<String> {
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<Vec<SearchResult>> {
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<Vec<SearchResult>> {
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<Vec<SearchResult>> {
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<Vec<SearchResult>> {
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())
}
-8
View File
@@ -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<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
pub internet_mode: super::model::settings::InternetMode,
pub origin: crate::app::state::types::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
}
@@ -67,7 +65,6 @@ pub struct ToolCtxBuilder {
pub download_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
pub internet_mode: super::model::settings::InternetMode,
pub origin: crate::app::state::types::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
}
@@ -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<dyn Tool>> {
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),
+2 -5
View File
@@ -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<String> {
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
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)
-42
View File
@@ -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();