modelbeast/mb
MODELBEAST 514ec6cdcc Security hardening from adversarial review (5-agent workflow)
Fixed confirmed findings before public exposure:
HIGH:
- upload filename path traversal → store.safe_name() strips to basename
- login rate-limit XFF bypass → key on request.client.host + per-username bucket;
  auth.check_login() burns bcrypt time on unknown users (no enumeration)
- cross-user read access → per-user isolation: guests see/use/download/delete only
  their own assets & jobs (owner sees all); WS job events scoped per-user
MEDIUM:
- unbounded upload read → bounded chunked streaming to the 1GB cap
- asset member path check → Path.is_relative_to boundary + ownership gate
- WS token-in-query-string leak → session-cookie-only WS auth
LOW:
- retry_job bypassed the per-user job cap → cap now checked on retry
- wholesale API-key injection → env_for_operator injects a paid key only to
  operators that declare it (guest local jobs never receive fal/OpenRouter keys)
- session revocation → users.session_epoch, bumped on password change
- int() 500s → 400; net-lane defense-in-depth (guests blocked by requires_env AND
  resources==net, so a mis-tagged paid op is still blocked)
+ public /api/health for serve.sh & proxy; docs/VPS.md; mb MB_TOKEN bearer auth

tests/smoke.sh: 34 checks passing incl. all new hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:25:13 +10:00

267 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""mb — headless CLI for MODELBEAST. Zero dependencies (python3 stdlib only),
works from any machine that can reach the server (tailnet included).
export MB_HOST=http://100.89.131.57:8777 # default: http://localhost:8777
mb ops [-v] list operators (+params with -v)
mb assets [--kind image] list assets
mb upload FILE [FILE...] upload files, print asset ids
mb run OP [--asset ID | --file F] [-p k=v ...] [--params-json J]
[--wait] [--follow] [--download DIR]
mb job ID job status json
mb jobs [-n 20] recent jobs
mb log ID [-f] print job log (-f = follow to completion)
mb wait ID [--download DIR] block until job finishes (exit 0/2/3)
mb get ASSET_ID [-o PATH] download an asset (folders: all members)
mb rm-asset ID / mb rm-job ID delete
mb retry ID / mb cancel ID job control
mb settings show settings (secrets masked)
mb set KEY VALUE set a setting (e.g. keys — value visible in
shell history; prefer the web UI for secrets)
Typical: mb run flux_local -p prompt="a brass astrolabe" -p seed=7 --wait --download out/
Exit codes: 0 ok/done, 1 usage/HTTP error, 2 job error, 3 job cancelled.
"""
import argparse
import json
import mimetypes
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
HOST = os.environ.get("MB_HOST", "http://localhost:8777").rstrip("/")
TOKEN = os.environ.get("MB_TOKEN", "")
def _auth(req):
if TOKEN:
req.add_header("Authorization", f"Bearer {TOKEN}")
return req
def http(method, path, body=None):
req = _auth(urllib.request.Request(HOST + path, method=method))
data = None
if body is not None:
data = json.dumps(body).encode()
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, data=data, timeout=120) as r:
ct = r.headers.get("content-type", "")
raw = r.read()
return json.loads(raw) if "json" in ct else raw
except urllib.error.HTTPError as e:
if e.code == 401:
sys.exit("401 unauthorized — set MB_TOKEN (owner: `mb token create <name>` in the UI, "
"or scripts/users.py). Get one from ⚙ Settings → Users → token.")
sys.exit(f"HTTP {e.code} {method} {path}: {e.read().decode()[:500]}")
except urllib.error.URLError as e:
sys.exit(f"cannot reach {HOST} ({e.reason}) — is the server up? set MB_HOST?")
def upload_file(path):
p = Path(path)
if not p.is_file():
sys.exit(f"no such file: {p}")
boundary = uuid.uuid4().hex
ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; "
f"filename=\"{p.name}\"\r\nContent-Type: {ctype}\r\n\r\n").encode()
body += p.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
req = _auth(urllib.request.Request(HOST + "/api/assets", data=body, method="POST"))
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
try:
with urllib.request.urlopen(req, timeout=600) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
sys.exit(f"upload failed: HTTP {e.code}: {e.read().decode()[:300]}")
def coerce(op, key, value):
"""Coerce k=v strings using the operator's JSON schema, else best-effort JSON."""
prop = (op.get("params_schema", {}).get("properties", {}) or {}).get(key, {})
t = prop.get("type")
try:
if t == "integer":
return int(value)
if t == "number":
return float(value)
if t == "boolean":
return value.lower() in ("1", "true", "yes", "on")
if t == "string":
return value
return json.loads(value)
except (ValueError, json.JSONDecodeError):
return value
def outputs_of(job_id):
return [a for a in http("GET", "/api/assets") if a.get("parent_job") == job_id]
def download_asset(asset, dest_dir):
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
url = f"{HOST}/api/assets/{asset['id']}/file"
with urllib.request.urlopen(_auth(urllib.request.Request(url)), timeout=600) as r:
ct = r.headers.get("content-type", "")
data = r.read()
if "json" in ct:
listing = json.loads(data)
if listing.get("folder"):
sub = dest_dir / asset["name"]
sub.mkdir(exist_ok=True)
for member in listing["files"]:
murl = url + "?member=" + urllib.request.quote(member)
with urllib.request.urlopen(_auth(urllib.request.Request(murl)), timeout=600) as r:
(sub / member).write_bytes(r.read())
print(f"{sub} ({len(listing['files'])} files)")
return
out = dest_dir / asset["name"]
out.write_bytes(data)
print(f"{out} ({len(data)} bytes)")
def wait_for(job_id, follow=False, download=None):
printed = 0
last_status = None
while True:
j = http("GET", f"/api/jobs/{job_id}")
if follow:
log = j.get("log") or ""
if len(log) > printed:
sys.stdout.write(log[printed:])
sys.stdout.flush()
printed = len(log)
elif j["status"] != last_status:
print(f"[{job_id}] {j['status']}")
last_status = j["status"]
if j["status"] in ("done", "error", "cancelled"):
if j["status"] == "error":
print(f"ERROR: {j.get('error')}\n--- last log ---\n{(j.get('log') or '')[-1500:]}",
file=sys.stderr)
sys.exit(2)
if j["status"] == "cancelled":
sys.exit(3)
outs = outputs_of(job_id)
print(f"done — {len(outs)} output(s):")
for a in outs:
print(f" {a['id']} {a['kind']:<14} {a['name']}")
if download:
for a in outs:
download_asset(a, download)
return
time.sleep(2)
def main():
ap = argparse.ArgumentParser(prog="mb", add_help=True)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("ops").add_argument("-v", action="store_true")
p = sub.add_parser("assets"); p.add_argument("--kind")
p = sub.add_parser("upload"); p.add_argument("files", nargs="+")
p = sub.add_parser("run")
p.add_argument("operator")
p.add_argument("--asset"); p.add_argument("--file")
p.add_argument("-p", "--param", action="append", default=[])
p.add_argument("--params-json")
p.add_argument("--wait", action="store_true")
p.add_argument("--follow", action="store_true")
p.add_argument("--download")
p = sub.add_parser("job"); p.add_argument("id")
p = sub.add_parser("jobs"); p.add_argument("-n", type=int, default=20)
p = sub.add_parser("log"); p.add_argument("id"); p.add_argument("-f", action="store_true")
p = sub.add_parser("wait"); p.add_argument("id"); p.add_argument("--download")
p = sub.add_parser("get"); p.add_argument("id"); p.add_argument("-o", default=".")
p = sub.add_parser("rm-asset"); p.add_argument("id")
p = sub.add_parser("rm-job"); p.add_argument("id")
p = sub.add_parser("retry"); p.add_argument("id")
p = sub.add_parser("cancel"); p.add_argument("id")
sub.add_parser("settings")
p = sub.add_parser("set"); p.add_argument("key"); p.add_argument("value")
a = ap.parse_args()
if a.cmd == "ops":
for op in http("GET", "/api/operators"):
gate = f" [needs {','.join(op['requires_env'])}]" if op.get("requires_env") else ""
inputs = ",".join(op["accepts"]) or "none (prompt-driven)"
print(f"{op['id']:<20} {op.get('category','?'):<11} in:{inputs:<18} "
f"lane:{op.get('resources','cpu')}{gate}")
if a.v:
for k, d in (op.get("params_schema", {}).get("properties", {}) or {}).items():
dv = f" (default {d['default']})" if "default" in d else ""
en = f" one of {d['enum']}" if "enum" in d else ""
print(f" -p {k}=<{d.get('type','any')}>{en}{dv} {d.get('description','')}")
elif a.cmd == "assets":
for x in http("GET", "/api/assets"):
if a.kind and x["kind"] != a.kind:
continue
print(f"{x['id']} {x['kind']:<14} {x['size']:>10} {x['name']}")
elif a.cmd == "upload":
for f in a.files:
asset = upload_file(f)
print(f"{asset['id']} {asset['kind']} {asset['name']}")
elif a.cmd == "run":
ops = {o["id"]: o for o in http("GET", "/api/operators")}
if a.operator not in ops:
sys.exit(f"unknown operator '{a.operator}'. Run: mb ops")
op = ops[a.operator]
asset_id = a.asset
if a.file:
asset_id = upload_file(a.file)["id"]
print(f"uploaded → {asset_id}")
if op["accepts"] and not asset_id:
sys.exit(f"{a.operator} needs an input ({','.join(op['accepts'])}): --asset ID or --file F")
params = {}
if a.params_json:
params.update(json.loads(a.params_json))
for kv in a.param:
if "=" not in kv:
sys.exit(f"bad -p '{kv}' (want k=v)")
k, v = kv.split("=", 1)
params[k] = coerce(op, k, v)
job = http("POST", "/api/jobs",
{"operator": a.operator, "asset_id": asset_id, "params": params})
print(f"job {job['id']} queued")
if a.wait or a.follow or a.download:
wait_for(job["id"], follow=a.follow, download=a.download)
elif a.cmd == "job":
print(json.dumps(http("GET", f"/api/jobs/{a.id}"), indent=2))
elif a.cmd == "jobs":
for j in http("GET", "/api/jobs")[: a.n]:
print(f"{j['id']} {j['status']:<9} {j['operator']}")
elif a.cmd == "log":
if a.f:
wait_for(a.id, follow=True)
else:
print(http("GET", f"/api/jobs/{a.id}").get("log") or "(empty)")
elif a.cmd == "wait":
wait_for(a.id, download=a.download)
elif a.cmd == "get":
assets = {x["id"]: x for x in http("GET", "/api/assets")}
if a.id not in assets:
sys.exit("no such asset")
download_asset(assets[a.id], a.o)
elif a.cmd == "rm-asset":
http("DELETE", f"/api/assets/{a.id}"); print("deleted")
elif a.cmd == "rm-job":
http("DELETE", f"/api/jobs/{a.id}"); print("deleted")
elif a.cmd == "retry":
j = http("POST", f"/api/jobs/{a.id}/retry"); print(f"new job {j['id']}")
elif a.cmd == "cancel":
http("POST", f"/api/jobs/{a.id}/cancel"); print("cancel requested")
elif a.cmd == "settings":
print(json.dumps(http("GET", "/api/settings"), indent=2))
elif a.cmd == "set":
http("PUT", "/api/settings", {a.key: a.value}); print("saved")
if __name__ == "__main__":
main()