feat(box): expand agent tool capabilities with files, web, and service ops
This commit is contained in:
@@ -290,6 +290,60 @@ case "$cmd" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
files)
|
||||||
|
sub="${1:-read}"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
read)
|
||||||
|
path="${1:?usage: box files read <path> [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 <path> <content>}"
|
||||||
|
content="${2:?usage: box files write <path> <content>}"
|
||||||
|
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 <url>}"
|
||||||
|
args=$(python3 -c "import json, sys; print(json.dumps({'url': sys.argv[1]}))" "$url")
|
||||||
|
call_exec "web.fetch" "$args"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: box web fetch <url>"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
service)
|
||||||
|
sub="${1:-status}"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
status)
|
||||||
|
unit="${1:?usage: box service status <unit>}"
|
||||||
|
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 <unit>}"
|
||||||
|
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 <unit>"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
health)
|
health)
|
||||||
call_exec "health.check" "{}"
|
call_exec "health.check" "{}"
|
||||||
;;
|
;;
|
||||||
@@ -318,6 +372,11 @@ Usage:
|
|||||||
box vars list
|
box vars list
|
||||||
box vars get <name>
|
box vars get <name>
|
||||||
box vars set <name> <value>
|
box vars set <name> <value>
|
||||||
|
box files read <path> [lines=100]
|
||||||
|
box files write <path> <content>
|
||||||
|
box web fetch <url>
|
||||||
|
box service status <unit>
|
||||||
|
box service restart <unit>
|
||||||
box health
|
box health
|
||||||
box ping
|
box ping
|
||||||
box ops
|
box ops
|
||||||
|
|||||||
Executable
+164
@@ -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()
|
||||||
+123
-1
@@ -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']]
|
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}
|
# op -> {validate, build, timeout, side_effecting, description}
|
||||||
OPS = {
|
OPS = {
|
||||||
'dm.send': {
|
'dm.send': {
|
||||||
@@ -701,6 +797,31 @@ OPS = {
|
|||||||
'timeout': 30, 'side_effecting': True,
|
'timeout': 30, 'side_effecting': True,
|
||||||
'desc': 'Update intrinsic loop variable',
|
'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': {
|
'exec.ping': {
|
||||||
'validate': _health_validate,
|
'validate': _health_validate,
|
||||||
'build': lambda a: ['/bin/echo', 'PONG'],
|
'build': lambda a: ['/bin/echo', 'PONG'],
|
||||||
@@ -891,7 +1012,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
).hexdigest()[:16]})
|
).hexdigest()[:16]})
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
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)
|
timeout=spec['timeout'], cwd=WORK_DIR)
|
||||||
rc = p.returncode
|
rc = p.returncode
|
||||||
out = p.stdout[-MAX_OUTPUT:]
|
out = p.stdout[-MAX_OUTPUT:]
|
||||||
|
|||||||
@@ -227,6 +227,39 @@ def format_tool_result_for_chat(op, raw_output):
|
|||||||
if op == "vars.get" and isinstance(data, dict):
|
if op == "vars.get" and isinstance(data, dict):
|
||||||
return f"{data.get('name')} = {data.get('value')}"
|
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
|
# General fallback: compact JSON capped to 400 chars
|
||||||
s = json.dumps(data)
|
s = json.dumps(data)
|
||||||
return s[:400] + "..." if len(s) > 400 else s
|
return s[:400] + "..." if len(s) > 400 else s
|
||||||
|
|||||||
Reference in New Issue
Block a user