feat(security-sidecar): implement a security tooling sidecar with tiered installer and protocol
- Add `zesdex_sec_daemon` module with main entry point for running the security daemon. - Implement `TieredInstaller` for installing security tools from various sources (pip, binaries, gems). - Create a newline-delimited JSON frame protocol for communication between the daemon and tools. - Introduce a `ToolRegistry` for managing and dispatching tool executions. - Add various tools including HTTP, SQLMap, Nuclei, and more with their respective execution logic. - Establish health check and installation commands for tool management. - Include prompts for classifier and quality reviewer to enhance code review and safety checks. - Document the system's tools and guidelines for usage.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
requests>=2.31.0
|
||||||
|
# Optional extras (install with: pip install zesdex-security-daemon[full])
|
||||||
|
# pycryptodome>=3.20.0
|
||||||
|
# factordb-python>=2.0.0
|
||||||
|
# pwntools>=4.12.0
|
||||||
|
# ropper>=1.13.0
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
[metadata]
|
||||||
|
name = zesdex-security-daemon
|
||||||
|
version = 0.1.0
|
||||||
|
description = Security tooling sidecar for zesdex — authorized pentesting/CTF/research tool dispatch
|
||||||
|
author = asepharyana
|
||||||
|
author_email = superaseph@gmail.com
|
||||||
|
|
||||||
|
[options]
|
||||||
|
packages = zesdex_sec_daemon
|
||||||
|
install_requires =
|
||||||
|
requests>=2.31.0
|
||||||
|
|
||||||
|
[options.extras_require]
|
||||||
|
web =
|
||||||
|
sqlmap>=1.8.0
|
||||||
|
python-nmap>=0.7.1
|
||||||
|
crypto =
|
||||||
|
pycryptodome>=3.20.0
|
||||||
|
factordb-python>=2.0.0
|
||||||
|
re =
|
||||||
|
wasm-decompile>=0.5.0
|
||||||
|
pwn =
|
||||||
|
pwntools>=4.12.0
|
||||||
|
ropper>=1.13.0
|
||||||
|
full =
|
||||||
|
%(web)s
|
||||||
|
%(crypto)s
|
||||||
|
%(re)s
|
||||||
|
%(pwn)s
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""zesdex-security-daemon: authorized security tooling sidecar.
|
||||||
|
|
||||||
|
Newline-delimited JSON frame protocol over stdin/stdout.
|
||||||
|
Single-threaded serialized dispatch with wall-clock timeout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .protocol import run_daemon, SecProtocolError
|
||||||
|
from .tools import ToolRegistry, ToolResult
|
||||||
|
from .installer import TieredInstaller
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"run_daemon",
|
||||||
|
"SecProtocolError",
|
||||||
|
"ToolRegistry",
|
||||||
|
"ToolResult",
|
||||||
|
"TieredInstaller",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Entry point: run the security daemon reading JSON frames from stdin."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .protocol import run_daemon
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if "--install" in sys.argv:
|
||||||
|
from .installer import TieredInstaller
|
||||||
|
installer = TieredInstaller()
|
||||||
|
result = installer.install_all()
|
||||||
|
print(result.model_dump_json())
|
||||||
|
return
|
||||||
|
if "--health" in sys.argv:
|
||||||
|
from .tools import ToolRegistry
|
||||||
|
registry = ToolRegistry()
|
||||||
|
health = registry.health_check()
|
||||||
|
import json
|
||||||
|
print(json.dumps(health))
|
||||||
|
return
|
||||||
|
run_daemon(sys.stdin, sys.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Tiered installer for security sidecar tools.
|
||||||
|
|
||||||
|
Installation tiers:
|
||||||
|
pip — Python packages (pycryptodome, pwntools, factordb-python, etc.)
|
||||||
|
binary — Pre-built GitHub release binaries (nuclei, ffuf, dalfox, etc.)
|
||||||
|
gem — Ruby gems (zap-cli, etc.)
|
||||||
|
detect — Manual-detect-only (z3, sage, etc. — user must install themselves)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InstallResult:
|
||||||
|
ok: bool = False
|
||||||
|
message: str = ""
|
||||||
|
installed: list[str] = field(default_factory=list)
|
||||||
|
failed: list[str] = field(default_factory=list)
|
||||||
|
skipped: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TieredInstaller:
|
||||||
|
"""Multi-tier installer for security tools.
|
||||||
|
|
||||||
|
Detects the current platform and tries each tier in order of
|
||||||
|
preference: pip -> binary -> gem -> detect (manual only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, target_dir: Optional[str] = None):
|
||||||
|
self.target_dir = Path(target_dir or self._default_bin_dir())
|
||||||
|
self.target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._pip_available = shutil.which("pip3") is not None or shutil.which("pip") is not None
|
||||||
|
self._gem_available = shutil.which("gem") is not None
|
||||||
|
self._arch = platform.machine()
|
||||||
|
self._os = platform.system().lower()
|
||||||
|
|
||||||
|
def _default_bin_dir(self) -> str:
|
||||||
|
if self._os == "linux":
|
||||||
|
return "/usr/local/bin"
|
||||||
|
return os.path.expanduser("~/.local/bin")
|
||||||
|
|
||||||
|
# ── pip tier ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _pip_install(self, pkg: str) -> bool:
|
||||||
|
pip = shutil.which("pip3") or shutil.which("pip")
|
||||||
|
if not pip:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[pip, "install", "--quiet", pkg],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── GitHub release binary tier ──────────────────────────────────
|
||||||
|
|
||||||
|
def _download_gh_release(
|
||||||
|
self, repo: str, asset_pattern: str,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
api_url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(api_url, headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "zesdex-security-daemon/0.1.0",
|
||||||
|
})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
assets = data.get("assets", [])
|
||||||
|
os_arch_tag = f"{self._os}_{self._arch}"
|
||||||
|
for asset in assets:
|
||||||
|
name = asset["name"]
|
||||||
|
if asset_pattern.replace("{os_arch}", os_arch_tag) in name:
|
||||||
|
download_url = asset["browser_download_url"]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Try without os_arch matching, just look for the pattern.
|
||||||
|
candidates = [a for a in assets if asset_pattern.split("/")[0] in a["name"]]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
download_url = candidates[0]["browser_download_url"]
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".tmp", delete=False) as tmp:
|
||||||
|
with urllib.request.urlopen(download_url, timeout=120) as dl:
|
||||||
|
tmp.write(dl.read())
|
||||||
|
tmppath = tmp.name
|
||||||
|
|
||||||
|
if download_url.endswith(".tar.gz"):
|
||||||
|
extract_dir = tempfile.mkdtemp()
|
||||||
|
with tarfile.open(tmppath, "r:gz") as tar:
|
||||||
|
tar.extractall(path=extract_dir)
|
||||||
|
os.unlink(tmppath)
|
||||||
|
# Find the binary in extracted files.
|
||||||
|
for root, _dirs, files in os.walk(extract_dir):
|
||||||
|
for fname in files:
|
||||||
|
if fname == asset_pattern.split("/")[0].replace(".tar.gz", ""):
|
||||||
|
return Path(root) / fname
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
result = Path(tmppath)
|
||||||
|
result.chmod(0o755)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _install_binary(self, name: str, repo: str, asset_pattern: str) -> bool:
|
||||||
|
downloaded = self._download_gh_release(repo, asset_pattern)
|
||||||
|
if downloaded is None:
|
||||||
|
return False
|
||||||
|
dest = self.target_dir / name
|
||||||
|
try:
|
||||||
|
shutil.move(str(downloaded), str(dest))
|
||||||
|
dest.chmod(0o755)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Gem tier ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _gem_install(self, gem_name: str) -> bool:
|
||||||
|
if not self._gem_available:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["gem", "install", "--quiet", gem_name],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Tool-specific installers ────────────────────────────────────
|
||||||
|
|
||||||
|
def _install_pip_tools(self) -> list[str]:
|
||||||
|
pkgs = [
|
||||||
|
"pycryptodome",
|
||||||
|
"pwntools",
|
||||||
|
"requests",
|
||||||
|
"factordb-python",
|
||||||
|
]
|
||||||
|
installed = []
|
||||||
|
for pkg in pkgs:
|
||||||
|
if self._pip_install(pkg):
|
||||||
|
installed.append(pkg)
|
||||||
|
return installed
|
||||||
|
|
||||||
|
def _install_binary_tools(self) -> list[str]:
|
||||||
|
targets = [
|
||||||
|
("nuclei", "projectdiscovery/nuclei", "nuclei_{os_arch}.tar.gz"),
|
||||||
|
("ffuf", "ffuf/ffuf", "ffuf_{os_arch}.tar.gz"),
|
||||||
|
("dalfox", "hahwul/dalfox", "dalfox_{os_arch}.tar.gz"),
|
||||||
|
("httpx", "projectdiscovery/httpx", "httpx_{os_arch}.tar.gz"),
|
||||||
|
]
|
||||||
|
installed = []
|
||||||
|
for name, repo, pattern in targets:
|
||||||
|
if shutil.which(name) is not None:
|
||||||
|
installed.append(name)
|
||||||
|
continue
|
||||||
|
if self._install_binary(name, repo, pattern):
|
||||||
|
installed.append(name)
|
||||||
|
return installed
|
||||||
|
|
||||||
|
def _install_gem_tools(self) -> list[str]:
|
||||||
|
installed = []
|
||||||
|
if self._gem_install("zap-cli"):
|
||||||
|
installed.append("zap-cli")
|
||||||
|
return installed
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def install_all(self) -> InstallResult:
|
||||||
|
result = InstallResult()
|
||||||
|
try:
|
||||||
|
pip_ok = self._install_pip_tools()
|
||||||
|
result.installed.extend(pip_ok)
|
||||||
|
except Exception as e:
|
||||||
|
result.failed.append(f"pip: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bin_ok = self._install_binary_tools()
|
||||||
|
result.installed.extend(bin_ok)
|
||||||
|
except Exception as e:
|
||||||
|
result.failed.append(f"binary: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
gem_ok = self._install_gem_tools()
|
||||||
|
result.installed.extend(gem_ok)
|
||||||
|
except Exception as e:
|
||||||
|
result.failed.append(f"gem: {e}")
|
||||||
|
|
||||||
|
result.ok = True
|
||||||
|
result.message = (
|
||||||
|
f"Installed {len(result.installed)} tool(s): "
|
||||||
|
f"{', '.join(result.installed)}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def install_tool(self, tool_name: str) -> dict:
|
||||||
|
"""Install a single tool by name. Returns {"ok": bool, "message": str}."""
|
||||||
|
pip_map = {
|
||||||
|
"pycryptodome": "pycryptodome",
|
||||||
|
"pwntools": "pwntools",
|
||||||
|
"factordb": "factordb-python",
|
||||||
|
"requests": "requests",
|
||||||
|
}
|
||||||
|
binary_map = {
|
||||||
|
"nuclei": ("projectdiscovery/nuclei", "nuclei_{os_arch}.tar.gz"),
|
||||||
|
"ffuf": ("ffuf/ffuf", "ffuf_{os_arch}.tar.gz"),
|
||||||
|
"dalfox": ("hahwul/dalfox", "dalfox_{os_arch}.tar.gz"),
|
||||||
|
"httpx": ("projectdiscovery/httpx", "httpx_{os_arch}.tar.gz"),
|
||||||
|
"sqlmap": ("sqlmapproject/sqlmap", "sqlmap.tar.gz"),
|
||||||
|
}
|
||||||
|
gem_map = {
|
||||||
|
"zap-cli": "zap-cli",
|
||||||
|
}
|
||||||
|
|
||||||
|
if tool_name in pip_map:
|
||||||
|
ok = self._pip_install(pip_map[tool_name])
|
||||||
|
return {"ok": ok, "message": f"pip install {pip_map[tool_name]}: {'ok' if ok else 'failed'}"}
|
||||||
|
if tool_name in binary_map:
|
||||||
|
repo, pattern = binary_map[tool_name]
|
||||||
|
ok = self._install_binary(tool_name, repo, pattern)
|
||||||
|
return {"ok": ok, "message": f"binary install {tool_name}: {'ok' if ok else 'failed'}"}
|
||||||
|
if tool_name in gem_map:
|
||||||
|
ok = self._gem_install(gem_map[tool_name])
|
||||||
|
return {"ok": ok, "message": f"gem install {gem_map[tool_name]}: {'ok' if ok else 'failed'}"}
|
||||||
|
return {"ok": False, "message": f"no installer available for {tool_name}; may need manual install"}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Newline-delimited JSON frame protocol for the security sidecar.
|
||||||
|
|
||||||
|
Frame format: one JSON object per line, terminated by LF.
|
||||||
|
Request: {"id": "<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()
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""Tool registry and dispatch for the security sidecar.
|
||||||
|
|
||||||
|
Each tool is a callable(subprocess_args, timeout_ms) -> ToolResult.
|
||||||
|
The registry maps tool names to implementations and provides health checking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolResult:
|
||||||
|
ok: bool
|
||||||
|
output: str = ""
|
||||||
|
error: str = ""
|
||||||
|
returncode: int = -1
|
||||||
|
timed_out: bool = False
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_subprocess(
|
||||||
|
cmd: list[str],
|
||||||
|
stdin_data: Optional[bytes] = None,
|
||||||
|
timeout_ms: int = 30_000,
|
||||||
|
cwd: Optional[Path] = None,
|
||||||
|
env: Optional[dict[str, str]] = None,
|
||||||
|
) -> ToolResult:
|
||||||
|
start = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
preexec_fn=lambda: signal.signal(signal.SIGXCPU, signal.SIG_DFL),
|
||||||
|
)
|
||||||
|
stdout_b, stderr_b = b"", b""
|
||||||
|
done = threading.Event()
|
||||||
|
|
||||||
|
def _reader():
|
||||||
|
nonlocal stdout_b, stderr_b
|
||||||
|
try:
|
||||||
|
stdout_b, stderr_b = proc.communicate(input=stdin_data, timeout=timeout_ms / 1000)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
stdout_b, stderr_b = proc.communicate()
|
||||||
|
finally:
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
reader_thread = threading.Thread(target=_reader, daemon=True)
|
||||||
|
reader_thread.start()
|
||||||
|
reader_thread.join(timeout=(timeout_ms / 1000) + 2)
|
||||||
|
if not done.is_set():
|
||||||
|
proc.kill()
|
||||||
|
reader_thread.join(1)
|
||||||
|
elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|
||||||
|
return ToolResult(
|
||||||
|
ok=False,
|
||||||
|
error=f"timed out after {timeout_ms}ms",
|
||||||
|
returncode=-signal.SIGKILL,
|
||||||
|
timed_out=True,
|
||||||
|
duration_ms=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = stdout_b.decode("utf-8", errors="replace")
|
||||||
|
if stderr_b:
|
||||||
|
combined += "\n" + stderr_b.decode("utf-8", errors="replace")
|
||||||
|
elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|
||||||
|
return ToolResult(
|
||||||
|
ok=proc.returncode == 0,
|
||||||
|
output=combined if proc.returncode == 0 else "",
|
||||||
|
error=combined if proc.returncode != 0 else "",
|
||||||
|
returncode=proc.returncode or 0,
|
||||||
|
duration_ms=elapsed,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|
||||||
|
return ToolResult(
|
||||||
|
ok=False,
|
||||||
|
error=f"executable not found: {cmd[0]}",
|
||||||
|
duration_ms=elapsed,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = int((datetime.now(timezone.utc) - start).total_seconds() * 1000)
|
||||||
|
return ToolResult(ok=False, error=str(e), duration_ms=elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_tool(name: str) -> bool:
|
||||||
|
return shutil.which(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Web tools ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _run_http(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
url = args.get("url", "")
|
||||||
|
method = args.get("method", "GET").upper()
|
||||||
|
headers = args.get("headers", {})
|
||||||
|
data = args.get("data", "")
|
||||||
|
if not url:
|
||||||
|
return ToolResult(ok=False, error="url is required")
|
||||||
|
cmd = ["curl", "-s", "-S", "-L", "-X", method]
|
||||||
|
for k, v in headers.items():
|
||||||
|
cmd.extend(["-H", f"{k}: {v}"])
|
||||||
|
if data and method in ("POST", "PUT", "PATCH"):
|
||||||
|
cmd.extend(["-d", data])
|
||||||
|
cmd.append(url)
|
||||||
|
return _run_subprocess(cmd, timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sqlmap(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
url = args.get("url", "")
|
||||||
|
if not url:
|
||||||
|
return ToolResult(ok=False, error="url is required")
|
||||||
|
cmd = ["sqlmap", "--batch", "--random-agent", "--time-sec", "5"]
|
||||||
|
if args.get("cookie"):
|
||||||
|
cmd.extend(["--cookie", args["cookie"]])
|
||||||
|
if args.get("data"):
|
||||||
|
cmd.extend(["--data", args["data"]])
|
||||||
|
if args.get("level"):
|
||||||
|
cmd.extend(["--level", str(args["level"])])
|
||||||
|
if args.get("risk"):
|
||||||
|
cmd.extend(["--risk", str(args["risk"])])
|
||||||
|
cmd.append(url)
|
||||||
|
return _run_subprocess(cmd, timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_nuclei(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
target = args.get("target", "")
|
||||||
|
if not target:
|
||||||
|
return ToolResult(ok=False, error="target is required")
|
||||||
|
cmd = ["nuclei", "-silent", "-no-color"]
|
||||||
|
if args.get("templates"):
|
||||||
|
cmd.extend(["-t", args["templates"]])
|
||||||
|
if args.get("severity"):
|
||||||
|
cmd.extend(["-severity", args["severity"]])
|
||||||
|
cmd.extend(["-u", target])
|
||||||
|
return _run_subprocess(cmd, timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ffuf(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
url = args.get("url", "")
|
||||||
|
wordlist = args.get("wordlist", "/usr/share/wordlists/dirb/common.txt")
|
||||||
|
if not url:
|
||||||
|
return ToolResult(ok=False, error="url is required")
|
||||||
|
cmd = ["ffuf", "-u", url, "-w", wordlist, "-ac", "-t", "40"]
|
||||||
|
if args.get("extensions"):
|
||||||
|
cmd.extend(["-e", args["extensions"]])
|
||||||
|
if args.get("fc"):
|
||||||
|
cmd.extend(["-fc", str(args["fc"])])
|
||||||
|
return _run_subprocess(cmd, timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dalfox(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
url = args.get("url", "")
|
||||||
|
if not url:
|
||||||
|
return ToolResult(ok=False, error="url is required")
|
||||||
|
cmd = ["dalfox", "url", url, "--silence", "--no-color", "--only-poc", "gfm"]
|
||||||
|
if args.get("cookie"):
|
||||||
|
cmd.extend(["--cookie", args["cookie"]])
|
||||||
|
if args.get("param"):
|
||||||
|
cmd.extend(["-p", args["param"]])
|
||||||
|
return _run_subprocess(cmd, timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_zap(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
target = args.get("target", "")
|
||||||
|
if not target:
|
||||||
|
return ToolResult(ok=False, error="target is required")
|
||||||
|
cmd = ["zap-cli", "--silent", "quick-scan", "-t", str(args.get("timeout", 60))]
|
||||||
|
if args.get("spider"):
|
||||||
|
cmd.append("--spider")
|
||||||
|
cmd.append(target)
|
||||||
|
return _run_subprocess(cmd, timeout_ms=timeout_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_xss_confirm(args: dict, timeout_ms: int) -> ToolResult:
|
||||||
|
payloads = args.get("payloads", [
|
||||||
|
"<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)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
You are a tool-call classifier for the Zesdex approval harness.
|
||||||
|
Given a tool name and its arguments, determine if the operation is safe.
|
||||||
|
|
||||||
|
Classification rules:
|
||||||
|
- read, grep, glob, search, dir_list: always SAFE
|
||||||
|
- write, edit: SAFE if path is inside a workspace root; SUSPICIOUS if
|
||||||
|
path targets system directories (/etc, /bin, /boot, /dev, /sys)
|
||||||
|
- delete: SAFE if inside workspace and not a critical path; DANGEROUS
|
||||||
|
if it matches recursive delete patterns outside workspace
|
||||||
|
- bash: SAFE for read-only commands (ls, cat, head, tail, ps, df);
|
||||||
|
DANGEROUS for destructive commands (rm -rf, dd, mkfs, >/dev/sdX)
|
||||||
|
- git_operator: SAFE for status/log/diff/commit; DANGEROUS for
|
||||||
|
force-push, reset --hard, clean -fdx, branch -D
|
||||||
|
- web_download: DANGEROUS if target path is outside workspace
|
||||||
|
- All other tools: SAFE by default
|
||||||
|
|
||||||
|
Output exactly one word: SAFE, SUSPICIOUS, or DANGEROUS.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
You are a code quality reviewer for Zesdex. Review recent code changes
|
||||||
|
for correctness, security, and adherence to best practices.
|
||||||
|
|
||||||
|
You have read-only access to the workspace. Use read, grep, glob, recall,
|
||||||
|
and remember tools to inspect files and save observations.
|
||||||
|
|
||||||
|
Review guidelines:
|
||||||
|
1. Check for common bugs: null/panic paths, off-by-one, race conditions,
|
||||||
|
unhandled errors, logic errors.
|
||||||
|
2. Check security: injection risks, unsafe deserialization, credential
|
||||||
|
exposure, path traversal.
|
||||||
|
3. Check conventions: does the code follow existing patterns in the
|
||||||
|
codebase? Check surrounding files for naming, structure, style.
|
||||||
|
4. Check the reason against the actual diff — does the reason match
|
||||||
|
what the code does?
|
||||||
|
|
||||||
|
If you find something worth remembering, call remember() with type="lesson".
|
||||||
|
Only call remember() if the observation is non-obvious and would benefit
|
||||||
|
future turns. Skip trivial style nits.
|
||||||
|
|
||||||
|
Before writing a new lesson, call recall() to check if a similar lesson
|
||||||
|
already exists. Deduplicate — don't write the same lesson twice.
|
||||||
|
|
||||||
|
Output: a one-line verdict summarizing your review.
|
||||||
|
Include "N lesson(s)" at the end if you created lessons.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
You are Zesdex, an autonomous AI coding and security agent operating in a
|
||||||
|
terminal-based TUI environment. Your goal is to help the user accomplish
|
||||||
|
software engineering tasks efficiently.
|
||||||
|
|
||||||
|
Core principles:
|
||||||
|
1. Be concise but thorough — prefer showing results over describing them.
|
||||||
|
2. Use the tools available to explore, understand, and modify the codebase.
|
||||||
|
3. For simple tasks, handle them directly with read/grep/write/edit.
|
||||||
|
4. For complex tasks (multi-file changes, parallel analysis, independent
|
||||||
|
verification), use workflow_run to orchestrate sub-agents.
|
||||||
|
5. Every write or edit must have a clear reason — include it in the reason
|
||||||
|
parameter.
|
||||||
|
6. When you're uncertain about requirements, ask clarifying questions
|
||||||
|
before acting.
|
||||||
|
7. After making changes, verify they work by running builds or tests.
|
||||||
|
8. Respect the agent mode: Auto (full autonomy), Normal (review risky ops),
|
||||||
|
Plan (no mutations), Yolo (full autonomy + no classifier).
|
||||||
|
|
||||||
|
Available tools are described in the system-tools.txt section. Use them
|
||||||
|
judiciously — prefer the simplest tool that accomplishes the task.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
You have access to the following tools. Use them to accomplish the user's request.
|
||||||
|
For simple operations (read, grep, write small edits) use tools directly.
|
||||||
|
For complex multi-step tasks that would benefit from parallel analysis or
|
||||||
|
independent verification, use workflow_run to orchestrate sub-agents.
|
||||||
|
|
||||||
|
Core tools:
|
||||||
|
- read(path) — Read file contents. Use when you need to inspect code.
|
||||||
|
- grep(pattern, path?) — Search for a pattern in files.
|
||||||
|
- glob(pattern) — List files matching a glob pattern.
|
||||||
|
- write(path, content, reason) — Write content to a file. Reason is required.
|
||||||
|
- edit(path, old, new, reason) — Replace text in a file. Reason is required.
|
||||||
|
- delete(path) — Delete a file or empty directory.
|
||||||
|
- bash(command) — Run a shell command. Use for builds, tests, git ops.
|
||||||
|
- bash_output(job_id) — Poll output of a background bash job.
|
||||||
|
- bash_kill(job_id) — Kill a background bash job.
|
||||||
|
- cd(path) — Change working directory.
|
||||||
|
- dir_list(path) — List directory contents.
|
||||||
|
- dir_cache_update() — Refresh the directory cache.
|
||||||
|
|
||||||
|
Git tools:
|
||||||
|
- git_operator(args, confirm_destructive?) — Run git commands. Some destructive
|
||||||
|
operations (force-push, reset --hard, branch -D) require confirm_destructive=true.
|
||||||
|
- git_worktree(args) — Manage git worktrees.
|
||||||
|
- git_cred(operation) — Manage git credentials.
|
||||||
|
|
||||||
|
Web tools:
|
||||||
|
- web_fetch(url) — Fetch a URL and return markdown content.
|
||||||
|
- web_search(query) — Search the web for information.
|
||||||
|
- web_download(url, path) — Download a file (max 500 MiB).
|
||||||
|
|
||||||
|
Memory & Planning:
|
||||||
|
- remember(text, type?) — Save to memory (type: lesson | reference | feedback).
|
||||||
|
- recall(query) — Search memory for relevant entries.
|
||||||
|
- forget(name) — Remove a memory entry.
|
||||||
|
- plan_enter() — Enter plan mode (for planning before changes).
|
||||||
|
- plan_ready() — Mark plan as ready for execution.
|
||||||
|
- seqthink(thought) — Record a chain-of-thought step.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
- workflow_run(script, args) — Fan out work to sub-agents. Use for complex
|
||||||
|
multi-step tasks needing parallel analysis or verification. Pass inline
|
||||||
|
scripts with agent(), parallel(), and pipeline() primitives.
|
||||||
|
|
||||||
|
Each write/edit call MUST include a non-empty reason argument explaining
|
||||||
|
why the change is being made. This is enforced deterministically.
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
|
||||||
|
|
||||||
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||||
if !command.is_empty() {
|
if !command.is_empty() {
|
||||||
let _job = crate::app::bgbash::job::spawn_bash_job(command);
|
let _ = crate::app::bgbash::job::spawn_bash_job(command);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
|
||||||
state.misc.overlay = Overlay::None;
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
|
|||||||
+206
-1
@@ -1,12 +1,217 @@
|
|||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EditorState {
|
||||||
|
pub path: String,
|
||||||
|
pub content: Vec<String>,
|
||||||
|
pub undo_stack: Vec<Vec<String>>,
|
||||||
|
pub cursor_line: usize,
|
||||||
|
pub cursor_col: usize,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
pub active: bool,
|
||||||
|
pub mode: EditorMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EditorMode {
|
||||||
|
Normal,
|
||||||
|
Insert,
|
||||||
|
Visual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EditorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
EditorState {
|
||||||
|
path: String::new(),
|
||||||
|
content: vec![String::new()],
|
||||||
|
undo_stack: Vec::new(),
|
||||||
|
cursor_line: 0,
|
||||||
|
cursor_col: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
active: false,
|
||||||
|
mode: EditorMode::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorState {
|
||||||
|
pub fn open(path: String, existing_content: Option<Vec<String>>) -> Self {
|
||||||
|
let content = existing_content.unwrap_or_else(|| vec![String::new()]);
|
||||||
|
EditorState {
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
active: true,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn change_line(&mut self, text: String) {
|
||||||
|
self.save_undo();
|
||||||
|
if self.cursor_line < self.content.len() {
|
||||||
|
self.content[self.cursor_line] = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_line_after(&mut self) {
|
||||||
|
self.save_undo();
|
||||||
|
let pos = (self.cursor_line + 1).min(self.content.len());
|
||||||
|
self.content.insert(pos, String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_current_line(&mut self) {
|
||||||
|
if self.content.len() <= 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.save_undo();
|
||||||
|
self.content.remove(self.cursor_line);
|
||||||
|
if self.cursor_line >= self.content.len() {
|
||||||
|
self.cursor_line = self.content.len() - 1;
|
||||||
|
}
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_undo(&mut self) {
|
||||||
|
self.undo_stack.push(self.content.clone());
|
||||||
|
if self.undo_stack.len() > 50 {
|
||||||
|
self.undo_stack.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self) {
|
||||||
|
if let Some(prev) = self.undo_stack.pop() {
|
||||||
|
self.content = prev;
|
||||||
|
self.cursor_line = self.cursor_line.min(self.content.len().saturating_sub(1));
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_up(&mut self) {
|
||||||
|
if self.cursor_line > 0 {
|
||||||
|
self.cursor_line -= 1;
|
||||||
|
}
|
||||||
|
self.cursor_col = self.cursor_col.min(
|
||||||
|
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_down(&mut self) {
|
||||||
|
if self.cursor_line + 1 < self.content.len() {
|
||||||
|
self.cursor_line += 1;
|
||||||
|
}
|
||||||
|
self.cursor_col = self.cursor_col.min(
|
||||||
|
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_left(&mut self) {
|
||||||
|
if self.cursor_col > 0 {
|
||||||
|
self.cursor_col -= 1;
|
||||||
|
} else if self.cursor_line > 0 {
|
||||||
|
self.cursor_line -= 1;
|
||||||
|
self.cursor_col = self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_right(&mut self) {
|
||||||
|
if let Some(line) = self.content.get(self.cursor_line) {
|
||||||
|
if self.cursor_col < line.len() {
|
||||||
|
self.cursor_col += 1;
|
||||||
|
} else if self.cursor_line + 1 < self.content.len() {
|
||||||
|
self.cursor_line += 1;
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_char(&mut self, c: char) {
|
||||||
|
self.save_undo();
|
||||||
|
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||||
|
line.insert(self.cursor_col, c);
|
||||||
|
self.cursor_col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_left(&mut self) {
|
||||||
|
self.save_undo();
|
||||||
|
if let Some(line) = self.content.get_mut(self.cursor_line) {
|
||||||
|
if self.cursor_col > 0 {
|
||||||
|
self.cursor_col -= 1;
|
||||||
|
line.remove(self.cursor_col);
|
||||||
|
} else if self.cursor_line > 0 {
|
||||||
|
let prev_len = self.content[self.cursor_line - 1].len();
|
||||||
|
let rest = self.content.remove(self.cursor_line);
|
||||||
|
self.cursor_line -= 1;
|
||||||
|
self.cursor_col = prev_len;
|
||||||
|
self.content[self.cursor_line].push_str(&rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_lines(&mut self) {
|
||||||
|
if self.cursor_line + 1 >= self.content.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.save_undo();
|
||||||
|
let next = self.content.remove(self.cursor_line + 1);
|
||||||
|
self.content[self.cursor_line].push_str(&next);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_string(&self) -> String {
|
||||||
|
self.content.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self) {
|
||||||
|
self.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mode(&mut self) {
|
||||||
|
self.mode = match self.mode {
|
||||||
|
EditorMode::Normal => EditorMode::Insert,
|
||||||
|
EditorMode::Insert => EditorMode::Normal,
|
||||||
|
EditorMode::Visual => EditorMode::Normal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppEditorState {
|
||||||
|
pub editor: Option<EditorState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppEditorState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
AppEditorState { editor: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||||
let _ = text;
|
let editor = &mut state.misc.editor;
|
||||||
|
if editor.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ed = editor.as_mut().unwrap();
|
||||||
|
for c in text.chars() {
|
||||||
|
match c {
|
||||||
|
'\n' | '\r' => {
|
||||||
|
ed.insert_line_after();
|
||||||
|
ed.cursor_down();
|
||||||
|
ed.cursor_col = 0;
|
||||||
|
}
|
||||||
|
'\t' => {
|
||||||
|
ed.insert_char(' ');
|
||||||
|
ed.insert_char(' ');
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ed.insert_char(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
||||||
|
state.misc.editor = None;
|
||||||
state.misc.overlay = Overlay::None;
|
state.misc.overlay = Overlay::None;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -2,13 +2,23 @@ use crate::app::state::rest::AppStateRest;
|
|||||||
|
|
||||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
pub fn current_effort(_state: &AppStateRest) -> usize {
|
pub fn current_effort(state: &AppStateRest) -> usize {
|
||||||
1
|
state.misc.effort_level.min(EFFORT_LEVELS.len() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_effort_str(state: &AppStateRest) -> &'static str {
|
||||||
|
let idx = current_effort(state);
|
||||||
|
EFFORT_LEVELS[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cycle_effort(state: &mut AppStateRest) {
|
pub fn cycle_effort(state: &mut AppStateRest) {
|
||||||
let current = current_effort(state);
|
let current = current_effort(state);
|
||||||
let next = (current + 1) % EFFORT_LEVELS.len();
|
state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len();
|
||||||
let _ = next;
|
state.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_effort(state: &mut AppStateRest, level: usize) {
|
||||||
|
let clamped = level.min(EFFORT_LEVELS.len() - 1);
|
||||||
|
state.misc.effort_level = clamped;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ pub fn toggle_security_arm(state: &mut AppStateRest) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn acknowledge_security(state: &mut AppStateRest) {
|
|
||||||
if !state.misc.security_acknowledged {
|
|
||||||
state.misc.security_acknowledged = true;
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_security_action(state: &mut AppStateRest, action: &Action) {
|
pub fn handle_security_action(state: &mut AppStateRest, action: &Action) {
|
||||||
if let Action::ToggleYoloArm = action {
|
if let Action::ToggleYoloArm = action {
|
||||||
toggle_security_arm(state);
|
toggle_security_arm(state);
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
use crate::app::runtime::actions::Action;
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
|
||||||
use crate::model::settings::{Settings, InternetMode};
|
use crate::model::settings::{Settings, InternetMode};
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
|
||||||
if let Action::ToggleYoloArm = action {
|
|
||||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cycle_internet_mode(settings: &mut Settings) {
|
pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||||
settings.internet_mode = match settings.internet_mode {
|
settings.internet_mode = match settings.internet_mode {
|
||||||
InternetMode::Off => InternetMode::ReadOnly,
|
InternetMode::Off => InternetMode::ReadOnly,
|
||||||
@@ -17,8 +7,3 @@ pub fn cycle_internet_mode(settings: &mut Settings) {
|
|||||||
InternetMode::Full => InternetMode::Off,
|
InternetMode::Full => InternetMode::Off,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn cycle_review_enabled(settings: &mut Settings) {
|
|
||||||
settings.review_enabled = !settings.review_enabled;
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-65
@@ -94,9 +94,6 @@ impl ReviewSystem {
|
|||||||
violation_window: 10,
|
violation_window: 10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a shadow hit for the given pattern. Returns true if the
|
|
||||||
/// trial window is complete and the check should be evaluated.
|
|
||||||
pub fn record_shadow_hit(&mut self, pattern: &str) -> bool {
|
pub fn record_shadow_hit(&mut self, pattern: &str) -> bool {
|
||||||
for check in &mut self.shadow_violations {
|
for check in &mut self.shadow_violations {
|
||||||
if check.pattern == pattern {
|
if check.pattern == pattern {
|
||||||
@@ -105,7 +102,6 @@ impl ReviewSystem {
|
|||||||
return check.trial_count >= check.trial_window;
|
return check.trial_count >= check.trial_window;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// First sighting: start a new shadow trial.
|
|
||||||
self.shadow_violations.push(ShadowCheck {
|
self.shadow_violations.push(ShadowCheck {
|
||||||
pattern: pattern.to_string(),
|
pattern: pattern.to_string(),
|
||||||
trial_window: 10,
|
trial_window: 10,
|
||||||
@@ -115,9 +111,6 @@ impl ReviewSystem {
|
|||||||
});
|
});
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate all shadow checks whose trial window is complete.
|
|
||||||
/// Graduates those with a high enough hit ratio; demotes the rest.
|
|
||||||
pub fn evaluate_shadow_trials(&mut self) -> Vec<String> {
|
pub fn evaluate_shadow_trials(&mut self) -> Vec<String> {
|
||||||
let mut graduated = Vec::new();
|
let mut graduated = Vec::new();
|
||||||
let mut remaining = Vec::new();
|
let mut remaining = Vec::new();
|
||||||
@@ -131,13 +124,11 @@ impl ReviewSystem {
|
|||||||
let tp = check.trial_passed;
|
let tp = check.trial_passed;
|
||||||
let tw = check.trial_window;
|
let tw = check.trial_window;
|
||||||
if ratio >= 0.3 {
|
if ratio >= 0.3 {
|
||||||
// Graduation threshold: fired on at least 30% of matching writes.
|
|
||||||
self.graduated_checks.push(crate::tool::GraduatedCheck {
|
self.graduated_checks.push(crate::tool::GraduatedCheck {
|
||||||
name: p.clone(),
|
name: p.clone(),
|
||||||
pattern: p.clone(),
|
pattern: p.clone(),
|
||||||
rule: p.clone(),
|
rule: p.clone(),
|
||||||
});
|
});
|
||||||
// Keep check as inactive so it doesn't re-process.
|
|
||||||
graduated.push(format!("{} (graduated, fired {}/{} writes)", p, tp, tw));
|
graduated.push(format!("{} (graduated, fired {}/{} writes)", p, tp, tw));
|
||||||
} else {
|
} else {
|
||||||
check.status = ShadowStatus::Rejected;
|
check.status = ShadowStatus::Rejected;
|
||||||
@@ -214,17 +205,13 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|||||||
if !state.settings.review_enabled {
|
if !state.settings.review_enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Always review if edits were made this turn.
|
|
||||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Adaptive skip: consecutive empty reviews throttle frequency.
|
|
||||||
// Backoff schedule: skip 0, 0, 1, 2, 4, 8... reviews between passes.
|
|
||||||
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
||||||
let consecutive = runtime.consecutive_empty_reviews;
|
let consecutive = runtime.consecutive_empty_reviews;
|
||||||
if consecutive >= base {
|
if consecutive >= base {
|
||||||
let skip = 1u32 << (consecutive - base).min(10); // max ~1024
|
let skip = 1u32 << (consecutive - base).min(10);
|
||||||
// Only trigger if the edit milestone aligns with the skip window.
|
|
||||||
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -232,8 +219,6 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of running the project's build/test verification.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProbeResult {
|
pub struct ProbeResult {
|
||||||
pub command: String,
|
pub command: String,
|
||||||
@@ -241,12 +226,6 @@ pub struct ProbeResult {
|
|||||||
pub output: String,
|
pub output: String,
|
||||||
pub timed_out: bool,
|
pub timed_out: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Language-agnostic build/test probe.
|
|
||||||
///
|
|
||||||
/// Uses settings.verify_command override first; falls back to probing for
|
|
||||||
/// well-known project markers in the workspace root. Returns None when no
|
|
||||||
/// marker or command matches (review proceeds on reasons+diff alone).
|
|
||||||
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
|
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
|
||||||
let probe_dir = workspaces.first()?;
|
let probe_dir = workspaces.first()?;
|
||||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||||
@@ -304,10 +283,7 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
|||||||
}
|
}
|
||||||
let has_file = |name: &str| probe_dir.join(name).exists();
|
let has_file = |name: &str| probe_dir.join(name).exists();
|
||||||
let has_dir = |name: &str| probe_dir.join(name).is_dir();
|
let has_dir = |name: &str| probe_dir.join(name).is_dir();
|
||||||
|
|
||||||
// Ordered probe: most specific/significant first.
|
|
||||||
if has_file("Cargo.toml") {
|
if has_file("Cargo.toml") {
|
||||||
// Rust workspace: cargo build first, then test if that passes.
|
|
||||||
if has_dir("src") || has_dir("tests") {
|
if has_dir("src") || has_dir("tests") {
|
||||||
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
|
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
|
||||||
}
|
}
|
||||||
@@ -320,7 +296,6 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
|||||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||||
let scripts = v.get("scripts")?;
|
let scripts = v.get("scripts")?;
|
||||||
// Prefer a "test" script, then "build".
|
|
||||||
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||||
return Some("npm test 2>&1".to_string());
|
return Some("npm test 2>&1".to_string());
|
||||||
}
|
}
|
||||||
@@ -328,11 +303,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
|||||||
return Some("npm run build 2>&1".to_string());
|
return Some("npm run build 2>&1".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Some("npm test 2>&1".to_string()); // best-effort fallback
|
return Some("npm test 2>&1".to_string());
|
||||||
}
|
}
|
||||||
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
|
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
|
||||||
if has_file("pyproject.toml") {
|
if has_file("pyproject.toml") {
|
||||||
// Check for pytest config in pyproject.toml
|
|
||||||
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
|
||||||
if content.contains("[tool.pytest") {
|
if content.contains("[tool.pytest") {
|
||||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||||
@@ -341,7 +315,6 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
|||||||
if has_dir("tests") || has_dir("test") {
|
if has_dir("tests") || has_dir("test") {
|
||||||
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
return Some("python -m pytest --tb=short -q 2>&1".to_string());
|
||||||
}
|
}
|
||||||
// No test dir: maybe a library or script project, skip verification.
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if has_file("Cargo.lock") {
|
if has_file("Cargo.lock") {
|
||||||
@@ -415,8 +388,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let mut ctx = build_subagent_context(def);
|
let mut ctx = build_subagent_context(def);
|
||||||
ctx.session_dir = state.session_dir.clone();
|
ctx.session_dir = state.session_dir.clone();
|
||||||
|
|
||||||
// Run build/test verification probe before spawning the reviewer.
|
|
||||||
let probe_result = probe_build_test(
|
let probe_result = probe_build_test(
|
||||||
&state.workspace_roots,
|
&state.workspace_roots,
|
||||||
state.settings.verify_command.as_deref(),
|
state.settings.verify_command.as_deref(),
|
||||||
@@ -480,9 +451,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called after a review subagent completes. Updates the empty-review counter
|
|
||||||
/// and checks for escalation on repeated violations.
|
|
||||||
pub fn record_review_outcome(
|
pub fn record_review_outcome(
|
||||||
lessons_found: usize,
|
lessons_found: usize,
|
||||||
state: &mut AppStateRest,
|
state: &mut AppStateRest,
|
||||||
@@ -493,20 +461,15 @@ pub fn record_review_outcome(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if lessons_found > 0 {
|
if lessons_found > 0 {
|
||||||
// Lesson found: reset empty counter.
|
|
||||||
runtime.consecutive_empty_reviews = 0;
|
runtime.consecutive_empty_reviews = 0;
|
||||||
runtime.review_count += 1;
|
runtime.review_count += 1;
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Empty review: increment counter.
|
|
||||||
runtime.consecutive_empty_reviews += 1;
|
runtime.consecutive_empty_reviews += 1;
|
||||||
runtime.review_count += 1;
|
runtime.review_count += 1;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check for repeated violations of a known lesson pattern and
|
|
||||||
/// produce an escalation note if threshold is crossed.
|
|
||||||
pub fn check_violation_escalation(
|
pub fn check_violation_escalation(
|
||||||
pattern: &str,
|
pattern: &str,
|
||||||
system: &mut ReviewSystem,
|
system: &mut ReviewSystem,
|
||||||
@@ -519,8 +482,6 @@ pub fn check_violation_escalation(
|
|||||||
ViolationEscalation::Block => Some(level),
|
ViolationEscalation::Block => Some(level),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an escalation note message for the UI.
|
|
||||||
pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> String {
|
pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> String {
|
||||||
let label = match level {
|
let label = match level {
|
||||||
ViolationEscalation::None => "none",
|
ViolationEscalation::None => "none",
|
||||||
@@ -549,8 +510,6 @@ pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> Stri
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lesson lifecycle: staleness sweep ──────────────────────────────
|
|
||||||
|
|
||||||
const STALE_AFTER_DAYS: i64 = 60;
|
const STALE_AFTER_DAYS: i64 = 60;
|
||||||
|
|
||||||
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
|
||||||
@@ -572,7 +531,6 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
|
|||||||
|
|
||||||
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
// Only run every 10 minutes at most.
|
|
||||||
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -587,26 +545,16 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contradiction detection ────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// A cheap, no-embedding heuristic: split the new text into normalized
|
|
||||||
// directive phrases ("always use X", "never use Y", "prefer Z") and check
|
|
||||||
// for an existing lesson with the *opposite* directive on the same topic.
|
|
||||||
|
|
||||||
pub fn detect_contradiction(
|
pub fn detect_contradiction(
|
||||||
new_text: &str,
|
new_text: &str,
|
||||||
existing_lessons: &[crate::model::memory::Memory],
|
existing_lessons: &[crate::model::memory::Memory],
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
// Normalize to lower-case words for comparison.
|
|
||||||
let new_words: std::collections::HashSet<String> = new_text
|
let new_words: std::collections::HashSet<String> = new_text
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.split(|c: char| !c.is_alphanumeric())
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
.filter(|w| w.len() >= 4 && !is_stop_word(w))
|
.filter(|w| w.len() >= 4 && !is_stop_word(w))
|
||||||
.map(|w| w.to_string())
|
.map(|w| w.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Quick check: does any existing lesson share >= 3 significant words
|
|
||||||
// but contain an opposing directive marker?
|
|
||||||
let opposite_markers = ["not", "never", "avoid", "don't", "do not", "instead"];
|
let opposite_markers = ["not", "never", "avoid", "don't", "do not", "instead"];
|
||||||
for existing in existing_lessons {
|
for existing in existing_lessons {
|
||||||
let existing_lower = existing.content.to_lowercase();
|
let existing_lower = existing.content.to_lowercase();
|
||||||
@@ -618,7 +566,6 @@ pub fn detect_contradiction(
|
|||||||
|
|
||||||
let shared = new_words.intersection(&exist_words).count();
|
let shared = new_words.intersection(&exist_words).count();
|
||||||
if shared >= 3 {
|
if shared >= 3 {
|
||||||
// Same topic -- check for opposing directive.
|
|
||||||
let new_has_opposite = opposite_markers.iter().any(|m| new_text.to_lowercase().contains(m));
|
let new_has_opposite = opposite_markers.iter().any(|m| new_text.to_lowercase().contains(m));
|
||||||
let old_has_opposite = opposite_markers.iter().any(|m| existing_lower.contains(m));
|
let old_has_opposite = opposite_markers.iter().any(|m| existing_lower.contains(m));
|
||||||
if new_has_opposite != old_has_opposite {
|
if new_has_opposite != old_has_opposite {
|
||||||
@@ -643,8 +590,6 @@ fn is_stop_word(w: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pending lesson calibration queue ───────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PendingLesson {
|
pub struct PendingLesson {
|
||||||
pub lesson: Lesson,
|
pub lesson: Lesson,
|
||||||
@@ -675,13 +620,10 @@ pub fn add_pending_lesson(session_dir: &std::path::Path, lesson: Lesson, auto_re
|
|||||||
});
|
});
|
||||||
save_pending_lessons(session_dir, &pending)
|
save_pending_lessons(session_dir, &pending)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process pending lessons: resolve auto-resolve ones (Auto/Yolo mode) after
|
|
||||||
/// a grace window, return ones that need explicit keypress.
|
|
||||||
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
|
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
|
||||||
let pending = load_pending_lessons(session_dir);
|
let pending = load_pending_lessons(session_dir);
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let grace_window = 5_000; // 5 seconds in Auto/Yolo mode
|
let grace_window = 5_000;
|
||||||
let mut remaining = Vec::new();
|
let mut remaining = Vec::new();
|
||||||
let mut to_keep = Vec::new();
|
let mut to_keep = Vec::new();
|
||||||
|
|
||||||
@@ -692,8 +634,6 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
|
|||||||
remaining.push(p.clone());
|
remaining.push(p.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write kept lessons to memory
|
|
||||||
for lesson in &to_keep {
|
for lesson in &to_keep {
|
||||||
let mem = crate::model::memory::Memory {
|
let mem = crate::model::memory::Memory {
|
||||||
name: lesson.name.clone(),
|
name: lesson.name.clone(),
|
||||||
@@ -715,8 +655,6 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
|
|||||||
save_pending_lessons(session_dir, &remaining)?;
|
save_pending_lessons(session_dir, &remaining)?;
|
||||||
Ok(remaining)
|
Ok(remaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a specific pending lesson (keep or discard).
|
|
||||||
pub fn resolve_pending_lesson(
|
pub fn resolve_pending_lesson(
|
||||||
session_dir: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
memory_dir: &std::path::Path,
|
memory_dir: &std::path::Path,
|
||||||
|
|||||||
@@ -58,16 +58,6 @@ pub enum Action {
|
|||||||
LessonReject {
|
LessonReject {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
#[expect(dead_code)]
|
|
||||||
RecordUsage {
|
|
||||||
tokens_in: u64,
|
|
||||||
tokens_out: u64,
|
|
||||||
duration_ms: u64,
|
|
||||||
},
|
|
||||||
#[expect(dead_code)]
|
|
||||||
RecordReviewTokens {
|
|
||||||
tokens: u64,
|
|
||||||
},
|
|
||||||
SaveSession,
|
SaveSession,
|
||||||
ResumeSession,
|
ResumeSession,
|
||||||
RefreshSessions,
|
RefreshSessions,
|
||||||
@@ -333,7 +323,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
Action::Tick => {
|
Action::Tick => {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
state.misc.drain_expired_toasts(now_ms);
|
state.misc.drain_expired_toasts(now_ms);
|
||||||
// Idle-time housekeeping.
|
|
||||||
crate::app::review::maybe_run_staleness_sweep(state);
|
crate::app::review::maybe_run_staleness_sweep(state);
|
||||||
if let Some(ref rt) = state.session_runtime {
|
if let Some(ref rt) = state.session_runtime {
|
||||||
let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir);
|
let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir);
|
||||||
@@ -385,10 +374,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
let _ = trigger_review(state);
|
let _ = trigger_review(state);
|
||||||
}
|
}
|
||||||
} else if kind == "review" {
|
} else if kind == "review" {
|
||||||
// Track review outcome: check if lessons were found.
|
|
||||||
// Format: "Quality review: <verdict> [N lesson(s)]"
|
|
||||||
let lessons_found = if message.contains("lesson") || message.contains("Lesson") {
|
let lessons_found = if message.contains("lesson") || message.contains("Lesson") {
|
||||||
// Check for "N lesson(s)" pattern at end
|
|
||||||
message.rsplit(' ').next().and_then(|w| {
|
message.rsplit(' ').next().and_then(|w| {
|
||||||
w.trim_end_matches(')').trim_end_matches('s')
|
w.trim_end_matches(')').trim_end_matches('s')
|
||||||
.split('(').next_back()
|
.split('(').next_back()
|
||||||
@@ -426,18 +412,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::RecordUsage { tokens_in, tokens_out, duration_ms } => {
|
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
|
||||||
rt.record_api_call(tokens_in, tokens_out, duration_ms);
|
|
||||||
}
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
Action::RecordReviewTokens { tokens } => {
|
|
||||||
if let Some(ref mut rt) = state.session_runtime {
|
|
||||||
rt.record_review_tokens(tokens);
|
|
||||||
}
|
|
||||||
state.dirty = true;
|
|
||||||
}
|
|
||||||
Action::LessonAccept { name } => {
|
Action::LessonAccept { name } => {
|
||||||
if let Some(ref rt) = state.session_runtime {
|
if let Some(ref rt) = state.session_runtime {
|
||||||
let _ = crate::app::review::resolve_pending_lesson(
|
let _ = crate::app::review::resolve_pending_lesson(
|
||||||
@@ -729,11 +703,9 @@ fn auto_create_retrospective(state: &mut AppStateRest) {
|
|||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Silently handle — retrospective is best-effort.
|
|
||||||
let _ = e;
|
let _ = e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Attempt consensus promotion for global-scope lessons.
|
|
||||||
let lessons: Vec<crate::model::memory::Memory> = crate::model::memory::Memory::list(&state.memory_dir)
|
let lessons: Vec<crate::model::memory::Memory> = crate::model::memory::Memory::list(&state.memory_dir)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok())
|
.filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok())
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
pub mod actions;
|
pub mod actions;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod event_loop;
|
pub mod event_loop;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod shortsend;
|
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Tool execution dispatch — superseded by inline per-tool call in
|
|
||||||
// app::runtime::actions::execute_one_tool within the SubmitInput loop.
|
|
||||||
// This module is preserved as a placeholder.
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
// Stream module — superseded by the inline tool-calling loop in
|
|
||||||
// app::runtime::actions (Action::SubmitInput / Tick pipeline).
|
|
||||||
// This module is preserved as a placeholder; all previous content
|
|
||||||
// has been removed since it duplicated logic now in actions/mod.rs.
|
|
||||||
|
|||||||
+207
-8
@@ -1,29 +1,228 @@
|
|||||||
use anyhow::Result;
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Instant;
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SecResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub output: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration_ms: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ts: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HealthResult {
|
||||||
|
pub tools: std::collections::HashMap<String, ToolHealth>,
|
||||||
|
pub available_count: usize,
|
||||||
|
pub total_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolHealth {
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SecDaemon {
|
pub struct SecDaemon {
|
||||||
pub pid: Option<u32>,
|
child: Option<Child>,
|
||||||
pub running: bool,
|
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 {
|
impl SecDaemon {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
SecDaemon {
|
SecDaemon {
|
||||||
pid: None,
|
child: None,
|
||||||
running: false,
|
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<()> {
|
pub fn start(&mut self) -> Result<()> {
|
||||||
self.running = true;
|
if self.running.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = Command::new("python3")
|
||||||
|
.arg("-m")
|
||||||
|
.arg("zesdex_sec_daemon")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn security daemon (python3 on PATH?)")?;
|
||||||
|
|
||||||
|
let child_stdin = child.stdin.take()
|
||||||
|
.ok_or_else(|| anyhow!("no stdin"))?;
|
||||||
|
let child_stdout = child.stdout.take()
|
||||||
|
.ok_or_else(|| anyhow!("no stdout"))?;
|
||||||
|
|
||||||
|
let resp_buf = self.response_buf.clone();
|
||||||
|
let running = self.running.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut reader = BufReader::new(child_stdout);
|
||||||
|
loop {
|
||||||
|
if !running.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut line = String::new();
|
||||||
|
match reader.read_line(&mut line) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {
|
||||||
|
if let Ok(mut buf) = resp_buf.lock() {
|
||||||
|
buf.push(line.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut stdin = Box::new(child_stdin) as Box<dyn Write + Send>;
|
||||||
|
let handshake = serde_json::json!({"op": "handshake", "token": self.token});
|
||||||
|
writeln!(stdin, "{}", handshake).context("handshake write failed")?;
|
||||||
|
stdin.flush()?;
|
||||||
|
self.child_stdin = Some(Mutex::new(stdin));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.running.store(true, Ordering::SeqCst);
|
||||||
|
self.child = Some(child);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self) -> Result<()> {
|
pub fn stop(&mut self) -> Result<()> {
|
||||||
self.running = false;
|
self.running.store(false, Ordering::SeqCst);
|
||||||
|
self.child_stdin = None;
|
||||||
|
if let Some(mut child) = self.child.take() {
|
||||||
|
child.kill().ok();
|
||||||
|
child.wait().ok();
|
||||||
|
}
|
||||||
Ok(())
|
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> {
|
pub fn health_check() -> Result<bool> {
|
||||||
Ok(true)
|
let path = crate::security::install::get_sidecar_path();
|
||||||
|
Ok(path.exists())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ pub struct MiscState {
|
|||||||
pub security_acknowledged: bool,
|
pub security_acknowledged: bool,
|
||||||
pub esc_press_count: u32,
|
pub esc_press_count: u32,
|
||||||
pub last_staleness_sweep_ms: i64,
|
pub last_staleness_sweep_ms: i64,
|
||||||
|
pub effort_level: usize,
|
||||||
|
pub editor: Option<crate::app::mode::editor::EditorState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MiscState {
|
impl MiscState {
|
||||||
@@ -182,6 +184,8 @@ impl MiscState {
|
|||||||
security_acknowledged: false,
|
security_acknowledged: false,
|
||||||
esc_press_count: 0,
|
esc_press_count: 0,
|
||||||
last_staleness_sweep_ms: 0,
|
last_staleness_sweep_ms: 0,
|
||||||
|
effort_level: 1,
|
||||||
|
editor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,29 +110,8 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
|
|||||||
Ok("workflow completed".to_string())
|
Ok("workflow completed".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn push_finding(engine: &mut WorkflowEngine, text: &str) {
|
|
||||||
engine.findings.push(text.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn note_finding(text: &str) {
|
pub fn note_finding(text: &str) {
|
||||||
if let Ok(mut findings) = FINDINGS.lock() {
|
if let Ok(mut findings) = FINDINGS.lock() {
|
||||||
findings.push(text.to_string());
|
findings.push(text.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn current_findings() -> Vec<String> {
|
|
||||||
if let Ok(findings) = FINDINGS.lock() {
|
|
||||||
findings.clone()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn clear_findings() {
|
|
||||||
if let Ok(mut findings) = FINDINGS.lock() {
|
|
||||||
findings.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ fn run_single_process() -> Result<()> {
|
|||||||
.internet_mode(state.settings.internet_mode.clone())
|
.internet_mode(state.settings.internet_mode.clone())
|
||||||
.origin(crate::app::state::types::Origin::Main)
|
.origin(crate::app::state::types::Origin::Main)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Read ToolCtx unused fields
|
|
||||||
let _ = &ctx.session_dir;
|
let _ = &ctx.session_dir;
|
||||||
let _ = &ctx.memory_dir;
|
let _ = &ctx.memory_dir;
|
||||||
let _ = &ctx.download_dir;
|
let _ = &ctx.download_dir;
|
||||||
@@ -108,8 +106,6 @@ fn run_single_process() -> Result<()> {
|
|||||||
let _ = tool::DEFERRED_TOOLS;
|
let _ = tool::DEFERRED_TOOLS;
|
||||||
let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test");
|
let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test");
|
||||||
let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test");
|
let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test");
|
||||||
|
|
||||||
// shell_filter function references
|
|
||||||
let _ = tool::shell_filter::credentials::check_credential_read("echo safe");
|
let _ = tool::shell_filter::credentials::check_credential_read("echo safe");
|
||||||
let _ = tool::shell_filter::git::check_git_destructive("git push");
|
let _ = tool::shell_filter::git::check_git_destructive("git push");
|
||||||
let _ = tool::shell_filter::git::check_git_destructive("git status");
|
let _ = tool::shell_filter::git::check_git_destructive("git status");
|
||||||
@@ -533,7 +529,6 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn run_loop(
|
fn run_loop(
|
||||||
state: &mut app::state::rest::AppStateRest,
|
state: &mut app::state::rest::AppStateRest,
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
@@ -548,7 +543,6 @@ fn run_loop(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn run_loop_inner(
|
fn run_loop_inner(
|
||||||
state: &mut app::state::rest::AppStateRest,
|
state: &mut app::state::rest::AppStateRest,
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
|||||||
@@ -158,25 +158,14 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
|||||||
std::fs::write(output, data)?;
|
std::fs::write(output, data)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Promote a lesson to global scope, requiring consensus.
|
|
||||||
/// Spawns two independent reviewers that must agree before the
|
|
||||||
/// lesson is written to ~/.zesdex/memory/.
|
|
||||||
pub fn promote_with_consensus(global_dir: &Path, lesson: &Memory) -> std::io::Result<bool> {
|
pub fn promote_with_consensus(global_dir: &Path, lesson: &Memory) -> std::io::Result<bool> {
|
||||||
let global_path = global_dir.join("memory");
|
let global_path = global_dir.join("memory");
|
||||||
std::fs::create_dir_all(&global_path)?;
|
std::fs::create_dir_all(&global_path)?;
|
||||||
|
|
||||||
// Check if already in global store.
|
|
||||||
let existing = Memory::list(&global_path);
|
let existing = Memory::list(&global_path);
|
||||||
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
||||||
if existing.contains(&slug) {
|
if existing.contains(&slug) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real implementation two independent reviewers would be spawned.
|
|
||||||
// For the infrastructure-level implementation, we use a simpler heuristic:
|
|
||||||
// if the lesson was born from a verified build failure, it's consensus-worthy.
|
|
||||||
// Otherwise, require an explicit human calibration.
|
|
||||||
let consensus = lesson.outcome.as_deref() == Some("verified");
|
let consensus = lesson.outcome.as_deref() == Some("verified");
|
||||||
|
|
||||||
if consensus {
|
if consensus {
|
||||||
@@ -204,22 +193,17 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize>
|
|||||||
}
|
}
|
||||||
Ok(imported)
|
Ok(imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Automatically create a retrospective for a session that has been
|
|
||||||
/// active for at least 60 seconds and has edits or lessons.
|
|
||||||
pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::io::Result<Option<Memory>> {
|
pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::io::Result<Option<Memory>> {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let session_age_ms = now.saturating_sub(session.created_at);
|
let session_age_ms = now.saturating_sub(session.created_at);
|
||||||
if session_age_ms < 60_000 {
|
if session_age_ms < 60_000 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Check if a retrospective already exists for this session.
|
|
||||||
let retro_name = format!("retrospective-{}", session.id);
|
let retro_name = format!("retrospective-{}", session.id);
|
||||||
let retro_path = Memory::path(session_dir, &retro_name);
|
let retro_path = Memory::path(session_dir, &retro_name);
|
||||||
if retro_path.exists() {
|
if retro_path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Collect lessons per-session-dir memory store.
|
|
||||||
let lessons: Vec<Memory> = Memory::list(session_dir)
|
let lessons: Vec<Memory> = Memory::list(session_dir)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|n| Memory::read(session_dir, n).ok())
|
.filter_map(|n| Memory::read(session_dir, n).ok())
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
#[expect(dead_code)]
|
|
||||||
pub mod pkce;
|
pub mod pkce;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod loopback;
|
pub mod loopback;
|
||||||
#[expect(dead_code)]
|
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
|
||||||
#[expect(unused_imports)]
|
|
||||||
pub use manager::{OAuthManager, OAuthConfig};
|
|
||||||
#[expect(unused_imports)]
|
|
||||||
pub use pkce::CodeVerifier;
|
|
||||||
#[expect(unused_imports)]
|
|
||||||
pub use loopback::LoopbackServer;
|
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ impl ToolCtxBuilder {
|
|||||||
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
|
pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self }
|
||||||
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
|
pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self }
|
||||||
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
|
||||||
#[expect(dead_code)]
|
|
||||||
pub fn graduated_checks(mut self, v: Vec<GraduatedCheck>) -> Self { self.graduated_checks = v; self }
|
pub fn graduated_checks(mut self, v: Vec<GraduatedCheck>) -> Self { self.graduated_checks = v; self }
|
||||||
pub fn build(self) -> ToolCtx {
|
pub fn build(self) -> ToolCtx {
|
||||||
ToolCtx {
|
ToolCtx {
|
||||||
|
|||||||
Reference in New Issue
Block a user