59c9965791
- daemon: route non-shell slot tasks to a fresh subagent session on dev/def/muse via muse_hybrid; add bin/ to sys.path so muse_hybrid/prompt_envelope import under the tmux supervisor - prompt_envelope: add wrap_subagent_task() - plain task assignment without [TOOL tmux/swarm/cron] meta tags (subagents refused those as relayed test traffic) - box-ctl/reporter: swarm-attach accepts optional session-id, stored as slot.subagent_session_id - executor: _looks_like_shell requires an existing executable (prose like 'verify ...' no longer misread as shell) - poller: pick up pending swarms as well as running - response-harvester: monitor running slot subagent sessions from swarms.json, allow '/' in RESULT/VERB job ids, archive ephemeral threads on any verdict (OK or FAIL), reap slot sessions for terminal swarms
203 lines
6.0 KiB
Python
203 lines
6.0 KiB
Python
"""Swarm worker task executor (Bridge Builder 2/5).
|
|
|
|
Executes a swarm slot's task text in a sandbox and captures the result.
|
|
|
|
Sandbox rules (hard):
|
|
- No network calls except to localhost.
|
|
- No writes outside /tmp.
|
|
- No sudo / privilege escalation.
|
|
- No credential access (no reading ~/.ssh, ~/.aws, key stores, etc).
|
|
- Strict timeout; kill the process group on exceed.
|
|
- Refuse tasks that look destructive without executing anything.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
|
|
OUTPUT_TRUNCATE = 2000
|
|
|
|
# Patterns that indicate a potentially destructive command. Matched against
|
|
# the raw task text (case-insensitive) before any execution is attempted.
|
|
_REFUSE_PATTERNS = [
|
|
r"\brm\s+-rf?\b", # rm -r / rm -rf
|
|
r"\brm\s+.*\s+/\s*$", # rm ... / (trailing root)
|
|
r"\bmkfs\b",
|
|
r"\bdd\b.*\bof=/dev/",
|
|
r"\bdd\b.*\bof=\s*/dev/",
|
|
r":\(\)\s*\{", # fork bomb
|
|
r"\bshutdown\b",
|
|
r"\breboot\b",
|
|
r"\bpoweroff\b",
|
|
r"\bhalt\b",
|
|
r"\binit\s+[06]\b",
|
|
r"\bsudo\b",
|
|
r"\bsu\b",
|
|
r"\bchmod\s+-R\s+777\s+/\b",
|
|
r"\bchown\s+-R\b.*\s+/\s*$",
|
|
r"\bmv\s+.*\s+/\s*$",
|
|
r"\bwipefs\b",
|
|
r"\bshred\b.*\s/dev/",
|
|
r">\s*/dev/sd",
|
|
r"\bcurl\b.*\|\s*(ba)?sh", # curl|sh pipe-to-shell
|
|
r"\bwget\b.*\|\s*(ba)?sh",
|
|
r"\bnc\b.*-e\s", # netcat reverse shell
|
|
r"\bpython\w*\s+-c\b.*socket",
|
|
]
|
|
|
|
_REFUSE_RE = re.compile("|".join("(?:%s)" % p for p in _REFUSE_PATTERNS),
|
|
re.IGNORECASE)
|
|
|
|
# Paths that must never be read (credential / identity material).
|
|
_FORBIDDEN_READ_PREFIXES = (
|
|
os.path.expanduser("~/.ssh"),
|
|
os.path.expanduser("~/.aws"),
|
|
os.path.expanduser("~/.gnupg"),
|
|
"/etc/shadow",
|
|
"/etc/netvm",
|
|
"/etc/ssl/private",
|
|
)
|
|
|
|
# A task is treated as a shell command when it is short, single-purpose,
|
|
# and does not look like prose/instructions. Heuristic: one or two lines,
|
|
# starts with a plausible command token, no sentence-like structure.
|
|
_PROSE_RE = re.compile(r"[.?!]\s+[A-Z]|\n\n|please\s|you\s+are\s|your\s+task",
|
|
re.IGNORECASE)
|
|
|
|
|
|
def _looks_like_shell(task_text):
|
|
"""Heuristic: is this plausibly a shell command?"""
|
|
t = task_text.strip()
|
|
if not t:
|
|
return False
|
|
if len(t.splitlines()) > 3:
|
|
return False
|
|
if _PROSE_RE.search(t):
|
|
return False
|
|
# Must start with a plausible executable command or path
|
|
first = t.split()[0] if t.split() else ""
|
|
if not re.match(r"^[a-zA-Z0-9_.\-/]+$", first):
|
|
return False
|
|
# If it's a relative/absolute path, verify it exists and is executable
|
|
if "/" in first:
|
|
return os.path.isfile(first) and os.access(first, os.X_OK)
|
|
# If it's a bare command name, it must exist in standard system bin paths
|
|
for p in ("/bin", "/usr/bin", "/usr/local/bin"):
|
|
candidate = os.path.join(p, first)
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _refused(task_text):
|
|
return bool(_REFUSE_RE.search(task_text))
|
|
|
|
|
|
def _sandbox_env():
|
|
"""Minimal environment: strip anything credential-shaped."""
|
|
env = {
|
|
"PATH": "/usr/bin:/bin",
|
|
"HOME": "/tmp",
|
|
"TMPDIR": "/tmp",
|
|
"LANG": "C.UTF-8",
|
|
}
|
|
return env
|
|
|
|
|
|
def execute_task(task_text, timeout=600):
|
|
"""Execute a swarm slot's task text in a sandbox.
|
|
|
|
Args:
|
|
task_text: the task string from the swarm slot.
|
|
timeout: max seconds for execution (default 600). Strictly enforced.
|
|
|
|
Returns:
|
|
dict(success=bool, output=str, duration_s=float, error=str|None)
|
|
"""
|
|
started = time.monotonic()
|
|
text = (task_text or "").strip()
|
|
|
|
def done(success, output, error=None):
|
|
dur = round(time.monotonic() - started, 3)
|
|
out = (output or "")[:OUTPUT_TRUNCATE]
|
|
return {
|
|
"success": success,
|
|
"output": out,
|
|
"duration_s": dur,
|
|
"error": error,
|
|
}
|
|
|
|
if not text:
|
|
return done(False, "", error="empty task")
|
|
|
|
if _refused(text):
|
|
return done(False, "", error="refused: potentially destructive")
|
|
|
|
if not _looks_like_shell(text):
|
|
return done(
|
|
True,
|
|
"received but not executable: task is prose/instructions, "
|
|
"not a shell command; recorded %d chars" % len(text),
|
|
error=None,
|
|
)
|
|
|
|
# Sandbox: run in /tmp, fresh process group so timeout kills children too.
|
|
try:
|
|
proc = subprocess.Popen(
|
|
text,
|
|
shell=True,
|
|
executable="/bin/bash",
|
|
cwd="/tmp",
|
|
env=_sandbox_env(),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
start_new_session=True, # new process group for reliable kill
|
|
)
|
|
except Exception as e: # e.g. /bin/bash missing
|
|
return done(False, "", error="spawn failed: %s" % e)
|
|
|
|
try:
|
|
stdout, _ = proc.communicate(timeout=timeout)
|
|
rc = proc.returncode
|
|
except subprocess.TimeoutExpired:
|
|
# Kill the whole process group.
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
stdout, _ = proc.communicate(timeout=5)
|
|
except Exception:
|
|
stdout = ""
|
|
return done(
|
|
False,
|
|
stdout or "",
|
|
error="timeout: exceeded %ss, process group killed" % timeout,
|
|
)
|
|
|
|
output = stdout or ""
|
|
if rc == 0:
|
|
return done(True, output)
|
|
return done(False, output, error="exit code %d" % rc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import json
|
|
import sys
|
|
|
|
cases = [
|
|
("echo hello", 10),
|
|
("rm -rf /", 10),
|
|
]
|
|
# Allow ad-hoc: python executor.py "<task>" [timeout]
|
|
if len(sys.argv) > 1:
|
|
cases = [(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 600)]
|
|
for task, to in cases:
|
|
print("TASK:", task)
|
|
print(json.dumps(execute_task(task, timeout=to), indent=1))
|
|
print("-" * 40)
|