From 2132d15cb44999e6d4dcd9b8e86fd1e3b11a4eda Mon Sep 17 00:00:00 2001 From: operator Date: Mon, 5 Oct 2026 03:51:43 +0000 Subject: [PATCH] feat(box): expand agent tool capabilities with files, web, and service ops --- bin/box-relay.sh | 59 ++++++++++++++ bin/box-sys-op.py | 164 ++++++++++++++++++++++++++++++++++++++ bin/exec-constrained.py | 124 +++++++++++++++++++++++++++- bin/response-harvester.py | 33 ++++++++ 4 files changed, 379 insertions(+), 1 deletion(-) create mode 100755 bin/box-sys-op.py diff --git a/bin/box-relay.sh b/bin/box-relay.sh index 0966147..b40256d 100755 --- a/bin/box-relay.sh +++ b/bin/box-relay.sh @@ -290,6 +290,60 @@ case "$cmd" in ;; esac ;; + files) + sub="${1:-read}" + shift || true + case "$sub" in + read) + path="${1:?usage: box files read [lines=100]}" + lines="${2:-100}" + args=$(python3 -c "import json, sys; print(json.dumps({'path': sys.argv[1], 'lines': int(sys.argv[2])}))" "$path" "$lines") + call_exec "files.read" "$args" + ;; + write) + path="${1:?usage: box files write }" + content="${2:?usage: box files write }" + args=$(python3 -c "import json, sys; print(json.dumps({'path': sys.argv[1], 'content': sys.argv[2]}))" "$path" "$content") + call_exec "files.write" "$args" + ;; + *) + echo "Usage: box files read|write ..." + ;; + esac + ;; + web) + sub="${1:-fetch}" + shift || true + case "$sub" in + fetch) + url="${1:?usage: box web fetch }" + args=$(python3 -c "import json, sys; print(json.dumps({'url': sys.argv[1]}))" "$url") + call_exec "web.fetch" "$args" + ;; + *) + echo "Usage: box web fetch " + ;; + esac + ;; + service) + sub="${1:-status}" + shift || true + case "$sub" in + status) + unit="${1:?usage: box service status }" + args=$(python3 -c "import json, sys; print(json.dumps({'unit': sys.argv[1]}))" "$unit") + call_exec "service.status" "$args" + ;; + restart) + unit="${1:?usage: box service restart }" + args=$(python3 -c "import json, sys; print(json.dumps({'unit': sys.argv[1]}))" "$unit") + call_exec "service.restart" "$args" + ;; + *) + echo "Usage: box service status|restart " + ;; + esac + ;; health) call_exec "health.check" "{}" ;; @@ -318,6 +372,11 @@ Usage: box vars list box vars get box vars set + box files read [lines=100] + box files write + box web fetch + box service status + box service restart box health box ping box ops diff --git a/bin/box-sys-op.py b/bin/box-sys-op.py new file mode 100755 index 0000000..2c54e55 --- /dev/null +++ b/bin/box-sys-op.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +box-sys-op.py — Sandboxed execution helper for core system operations: + files.read, files.write, web.fetch, service.status, service.restart +Called via fixed argv from exec-constrained.py. +""" +import sys +import os +import json +import urllib.request +import urllib.error +import urllib.parse +import ipaddress +import subprocess +from pathlib import Path + +REPO_ROOT = Path("/home/super/Projects/NetVM").resolve() +MAX_OUTPUT = 4096 +ALLOWED_SERVICES = { + "board.service", "caddy.service", "response-harvester.timer", + "self-main-loop.timer", "job-heartbeat.timer", "job-scheduler.timer" +} + +def safe_repo_path(raw): + clean = os.path.normpath(raw.strip()) + if not os.path.isabs(clean): + clean = os.path.normpath(str(REPO_ROOT / clean)) + real = Path(clean).resolve() + if not str(real).startswith(str(REPO_ROOT) + "/") and real != REPO_ROOT: + raise ValueError("path must reside inside repository root (/home/super/Projects/NetVM)") + return real + +def op_files_read(path_str, max_lines=100): + p = safe_repo_path(path_str) + if not p.exists() or not p.is_file(): + return {"ok": False, "error": f"File not found: {path_str}"} + with open(p, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + total_lines = len(lines) + snippet = "".join(lines[:max_lines]) + truncated = total_lines > max_lines or len(snippet) > MAX_OUTPUT + if len(snippet) > MAX_OUTPUT: + snippet = snippet[:MAX_OUTPUT] + "\n... [truncated]" + return { + "ok": True, + "path": str(p.relative_to(REPO_ROOT)), + "lines": total_lines, + "displayed_lines": min(total_lines, max_lines), + "content": snippet, + "truncated": truncated + } + +def op_files_write(path_str, content): + p = safe_repo_path(path_str) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + f.write(content) + return { + "ok": True, + "path": str(p.relative_to(REPO_ROOT)), + "bytes_written": len(content.encode("utf-8")), + "lines": content.count("\n") + 1 + } + +def op_web_fetch(url_str): + parsed = urllib.parse.urlparse(url_str) + if parsed.scheme not in ("http", "https"): + return {"ok": False, "error": "URL scheme must be http or https"} + host = parsed.hostname or "" + if not host or host in ("localhost", "127.0.0.1", "::1"): + return {"ok": False, "error": "Loopback destinations blocked"} + try: + ip = ipaddress.ip_address(host) + if ip.is_private or ip.is_loopback or ip.is_link_local: + return {"ok": False, "error": "Private and local IP addresses blocked"} + except ValueError: + pass + + req = urllib.request.Request( + url_str, + headers={"User-Agent": "Mozilla/5.0 Box-Agent-Client/1.0"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = resp.read(MAX_OUTPUT + 1024).decode("utf-8", errors="replace") + status = resp.status + truncated = len(data) > MAX_OUTPUT + if truncated: + data = data[:MAX_OUTPUT] + "\n... [truncated]" + return { + "ok": True, + "url": url_str, + "status": status, + "length": len(data), + "body": data, + "truncated": truncated + } + except urllib.error.HTTPError as he: + return {"ok": False, "status": he.code, "error": f"HTTP {he.code}: {he.reason}"} + except Exception as e: + return {"ok": False, "error": str(e)} + +def op_service_status(unit): + if unit not in ALLOWED_SERVICES: + return {"ok": False, "error": f"Service not allowed: {unit}"} + flag = "--user" if unit.endswith(".timer") else "--system" + cmd = ["systemctl", flag, "status", unit] if flag == "--user" else ["systemctl", "is-active", unit] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + active = "active" in r.stdout.lower() or "active" in r.stderr.lower() + return { + "ok": True, + "service": unit, + "active": active, + "status_line": r.stdout.splitlines()[0] if r.stdout.splitlines() else "unknown", + "output": r.stdout[:500].strip() + } + +def op_service_restart(unit): + if unit not in ALLOWED_SERVICES: + return {"ok": False, "error": f"Service not allowed: {unit}"} + if unit.endswith(".timer"): + cmd = ["systemctl", "--user", "restart", unit] + else: + cmd = ["sudo", "-n", "systemctl", "restart", unit] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + return { + "ok": r.returncode == 0, + "service": unit, + "restarted": r.returncode == 0, + "error": r.stderr.strip() if r.returncode != 0 else None + } + +def main(): + if len(sys.argv) < 2: + print(json.dumps({"ok": False, "error": "missing operation"})) + sys.exit(1) + op = sys.argv[1] + raw_args = sys.stdin.read() + try: + args = json.loads(raw_args) if raw_args.strip() else {} + except Exception as e: + print(json.dumps({"ok": False, "error": f"bad json args: {e}"})) + sys.exit(1) + + try: + if op == "files.read": + res = op_files_read(args.get("path", ""), int(args.get("lines", 100))) + elif op == "files.write": + res = op_files_write(args.get("path", ""), args.get("content", "")) + elif op == "web.fetch": + res = op_web_fetch(args.get("url", "")) + elif op == "service.status": + res = op_service_status(args.get("unit", args.get("name", ""))) + elif op == "service.restart": + res = op_service_restart(args.get("unit", args.get("name", ""))) + else: + res = {"ok": False, "error": f"unknown operation: {op}"} + except Exception as e: + res = {"ok": False, "error": str(e)} + + print(json.dumps(res)) + +if __name__ == "__main__": + main() diff --git a/bin/exec-constrained.py b/bin/exec-constrained.py index bc32b1b..14d7675 100755 --- a/bin/exec-constrained.py +++ b/bin/exec-constrained.py @@ -609,6 +609,102 @@ def _vars_set_build(a): return [sys.executable, os.path.join(BIN_DIR, 'box-ctl.py'), 'vars-set', a['name'], a['value']] +def _files_read_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'path', 'lines'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + p = raw.get('path') + if not isinstance(p, str) or not p.strip() or '..' in p: + raise OpError('path must be a safe relative or repo path without ".."') + return { + 'path': p.strip(), + 'lines': _opt_int(raw.get('lines', 100), 1, 1000, 'lines') or 100 + } + + +def _files_read_build(a): + # Pass JSON args via stdin to box-sys-op.py + return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'files.read'] + + +def _files_write_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'path', 'content'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + p = raw.get('path') + if not isinstance(p, str) or not p.strip() or '..' in p: + raise OpError('path must be a safe relative or repo path without ".."') + content = raw.get('content') + if not isinstance(content, str): + raise OpError('content must be a string') + if len(content.encode('utf-8')) > 64 * 1024: + raise OpError('content exceeds max size 64KB') + return {'path': p.strip(), 'content': content} + + +def _files_write_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'files.write'] + + +def _web_fetch_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'url'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + u = raw.get('url') + if not isinstance(u, str) or not u.strip(): + raise OpError('url must be a string') + if not (u.startswith('http://') or u.startswith('https://')): + raise OpError('url must start with http:// or https://') + return {'url': u.strip()} + + +def _web_fetch_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'web.fetch'] + + +def _service_status_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'unit', 'name'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + unit = raw.get('unit') or raw.get('name') + if not isinstance(unit, str) or not NAME_RE.fullmatch(unit): + raise OpError('unit must match safe identifier') + return {'unit': unit} + + +def _service_status_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'service.status'] + + +def _service_restart_validate(raw): + if not isinstance(raw, dict): + raise OpError('args must be an object') + allowed = {'unit', 'name'} + for k in raw: + if k not in allowed: + raise OpError(f'unknown arg: {k}') + unit = raw.get('unit') or raw.get('name') + if not isinstance(unit, str) or not NAME_RE.fullmatch(unit): + raise OpError('unit must match safe identifier') + return {'unit': unit} + + +def _service_restart_build(a): + return [sys.executable, os.path.join(BIN_DIR, 'box-sys-op.py'), 'service.restart'] + + # op -> {validate, build, timeout, side_effecting, description} OPS = { 'dm.send': { @@ -701,6 +797,31 @@ OPS = { 'timeout': 30, 'side_effecting': True, 'desc': 'Update intrinsic loop variable', }, + 'files.read': { + 'validate': _files_read_validate, 'build': _files_read_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Read sandboxed file within repository tree', + }, + 'files.write': { + 'validate': _files_write_validate, 'build': _files_write_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Write sandboxed file within repository tree', + }, + 'web.fetch': { + 'validate': _web_fetch_validate, 'build': _web_fetch_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Perform safe HTTP/HTTPS GET request with SSRF guard', + }, + 'service.status': { + 'validate': _service_status_validate, 'build': _service_status_build, + 'timeout': 30, 'side_effecting': False, + 'desc': 'Inspect allowlisted fleet systemd service status', + }, + 'service.restart': { + 'validate': _service_restart_validate, 'build': _service_restart_build, + 'timeout': 30, 'side_effecting': True, + 'desc': 'Restart allowlisted fleet systemd service', + }, 'exec.ping': { 'validate': _health_validate, 'build': lambda a: ['/bin/echo', 'PONG'], @@ -891,7 +1012,8 @@ class Handler(BaseHTTPRequestHandler): ).hexdigest()[:16]}) t0 = time.monotonic() try: - p = subprocess.run(argv, capture_output=True, text=True, + stdin_input = json.dumps(clean) if op.startswith(('files.', 'web.', 'service.')) else None + p = subprocess.run(argv, input=stdin_input, capture_output=True, text=True, timeout=spec['timeout'], cwd=WORK_DIR) rc = p.returncode out = p.stdout[-MAX_OUTPUT:] diff --git a/bin/response-harvester.py b/bin/response-harvester.py index 3f21a3b..172b251 100755 --- a/bin/response-harvester.py +++ b/bin/response-harvester.py @@ -227,6 +227,39 @@ def format_tool_result_for_chat(op, raw_output): if op == "vars.get" and isinstance(data, dict): return f"{data.get('name')} = {data.get('value')}" + if op == "files.read" and isinstance(data, dict): + if not data.get("ok"): + return f"Failed to read file: {data.get('error')}" + path = data.get("path") + lines = data.get("lines") + trunc = " (first " + str(data.get("displayed_lines")) + " lines)" if data.get("truncated") else "" + content = data.get("content", "").strip() + return f"File `{path}` ({lines} lines total){trunc}:\n```\n{content}\n```" + + if op == "files.write" and isinstance(data, dict): + if not data.get("ok"): + return f"Failed to write file: {data.get('error')}" + return f"File `{data.get('path')}` written successfully ({data.get('bytes_written')} bytes, {data.get('lines')} lines)." + + if op == "web.fetch" and isinstance(data, dict): + if not data.get("ok"): + return f"Failed to fetch {data.get('url')}: {data.get('error')}" + status = data.get("status") + url = data.get("url") + trunc = " (truncated to 4KB)" if data.get("truncated") else "" + return f"Web Fetch `{url}` (HTTP {status}){trunc}:\n```\n{data.get('body', '').strip()[:800]}\n```" + + if op == "service.status" and isinstance(data, dict): + if not data.get("ok"): + return f"Service check failed: {data.get('error')}" + st = "ACTIVE" if data.get("active") else "INACTIVE" + return f"Service `{data.get('service')}` is {st}.\nStatus: {data.get('status_line')}" + + if op == "service.restart" and isinstance(data, dict): + if not data.get("ok"): + return f"Service restart failed for `{data.get('service')}`: {data.get('error')}" + return f"Service `{data.get('service')}` restarted successfully." + # General fallback: compact JSON capped to 400 chars s = json.dumps(data) return s[:400] + "..." if len(s) > 400 else s