"""moth's bash tool at serve time: a tiny pure-Python shell, no subprocess, no bubblewrap. The model only ever learned a handful of commands (results/r3: ls / cat cover ~93% of its bash calls). This implements exactly that grammar, scoped to the home folder, and answers byte for byte like the bwrap'd GNU bash it was trained against (tiny_agent/tools.py: HOME=/work, cwd /work, LANG=C.UTF-8): ls [-1aAR] [path ...] one name per line (not a tty), C.UTF-8 = code point order, dir headers when >1 arg cat path ... head [-n N | -N] path ... wc [-lwcm] path ... echo [-n] words ... bash's builtin: arguments joined by spaces (no -e escapes) ~ and ~/x expand to /work; /work/x and relative paths are inside the home; ~name stays literal (no passwd) Output is built like tools.Workspace.tool_bash: stdout, then stderr, then "\\n[exit code N]", stripped, "(no output)" when empty, truncated at MAX_RESULT_CHARS. Error lines use coreutils' own wording and quoting. Paths that leave the home (/etc, .., a symlink out) answer "No such file or directory". Anything else (pipes, redirects, globs, $vars, other programs) gets REFUSE. Works the same on Linux, macOS and Windows. """ from __future__ import annotations import errno import os import re from tiny_agent.tools import MAX_RESULT_CHARS, truncate WORK = "/work" # what the model believes its home is called MARKER = ".moth-home" # written by serve.Home when it creates a home; invisible to the shell REFUSE = "moth can only use ls, cat, head, wc and echo here." HINT = ", narrow the command (e.g. | head, grep)" _UNQUOTED_META = set("|&;<>()$`*?[{}\n\r") _REASON = {errno.ENOENT: "No such file or directory", errno.ENOTDIR: "Not a directory", errno.EISDIR: "Is a directory", errno.EACCES: "Permission denied"} class Unsupported(Exception): pass # --- words -------------------------------------------------------------------------------------------------- def split(command: str) -> list[str]: """bash word splitting for the plain subset: quotes, backslashes and tilde expansion; anything that would need a real shell (pipes, redirects, globs, $, `, braces, a second line) raises Unsupported.""" words, cur, started, tilde_ok = [], [], False, False i, n = 0, len(command) while i < n: c = command[i] if c in " \t": if started: words.append(_tilde("".join(cur), tilde_ok)) cur, started = [], False i += 1 continue if not started: started, tilde_ok = True, c == "~" if c == "#": raise Unsupported("comment") if c == "'": j = command.find("'", i + 1) if j < 0: raise Unsupported("open quote") cur.append(command[i + 1:j]) i = j + 1 elif c == '"': i += 1 while True: if i >= n: raise Unsupported("open quote") c = command[i] if c == '"': i += 1 break if c in "$`": raise Unsupported("expansion") if c == "\\" and i + 1 < n and command[i + 1] in '"\\$`': cur.append(command[i + 1]) i += 2 continue cur.append(c) i += 1 elif c == "\\": if i + 1 >= n or command[i + 1] == "\n": raise Unsupported("line continuation") cur.append(command[i + 1]) i += 2 elif c in _UNQUOTED_META: raise Unsupported(c) else: cur.append(c) i += 1 if started: words.append(_tilde("".join(cur), tilde_ok)) return words def _tilde(word: str, ok: bool) -> str: """~ and ~/x -> /work...; ~+ is $PWD (also /work); ~name and ~- have nothing to expand to in the sandbox.""" if not ok: return word head, sep, rest = word.partition("/") if head in ("~", "~+"): return WORK + sep + rest return word # --- coreutils' quoting of file names in messages (gnulib quotearg, shell-escape styles) --------------------- _SPECIAL = set(" \t\n!\"$&'()*;<=>?[\\^`|") # plus # ~ first, { } alone, : in quotef def quote(name: str, always: bool) -> str: """always=True: quote() (ls "cannot access", head "cannot open"); False: quotef() (cat, wc).""" if name == "": return "''" if any(ord(c) < 32 or ord(c) == 127 for c in name): # 'tab'$'\t''x' s = "'" for c in name: if ord(c) < 32 or ord(c) == 127: s += "'$'\\" + _CESC.get(c, f"{ord(c):03o}") + "''" else: s += "'\\''" if c == "'" else c s += "'" return s[:-2] if s.endswith("''") else s needs = (always or name[0] in "#~" or name in ("{", "}") or any(c in _SPECIAL for c in name) or ":" in name) if not needs: return name if "'" in name: if not any(c in '"$`\\!' for c in name): return f'"{name}"' return "'" + name.replace("'", "'\\''") + "'" return f"'{name}'" _CESC = {"\a": "a", "\b": "b", "\t": "t", "\n": "n", "\v": "v", "\f": "f", "\r": "r"} # --- the shell ---------------------------------------------------------------------------------------------- class Shell: def __init__(self, root: str): self.root = os.path.realpath(root) def run(self, command: str) -> str: """The tool result for a `bash` call, formatted like tools.Workspace.tool_bash.""" try: words = split(command) except Unsupported: return REFUSE if not words: return "(no output)" fn = {"ls": self.ls, "cat": self.cat, "head": self.head, "wc": self.wc, "echo": self.echo}.get(words[0]) if fn is None: return REFUSE out, err = [], [] try: code = fn(words[1:], out, err) except Unsupported: return REFUSE text = (b"".join(out) + "".join(err).encode()).decode("utf-8", "replace") if code: text += f"\n[exit code {code}]" return truncate(text.strip() or "(no output)", MAX_RESULT_CHARS, hint=HINT) # paths ------------------------------------------------------------------------------------------------ def locate(self, path: str) -> str: """The real path for a path as the model wrote it (after ~ expansion). Raises OSError like the kernel would, and ENOENT for anything outside the home (or the marker file).""" if not path: raise OSError(errno.ENOENT, "empty") if path.startswith("/"): if path != WORK and not path.startswith(WORK + "/"): raise OSError(errno.ENOENT, "outside") path = path[len(WORK):].lstrip("/") or "." if os.name == "nt" and _windows_unsafe(path): raise OSError(errno.ENOENT, "outside") full = os.path.join(self.root, path) real = os.path.realpath(full) if real != self.root and not real.startswith(self.root + os.sep): raise OSError(errno.ENOENT, "outside") if real.casefold() == os.path.join(self.root, MARKER).casefold(): # macOS / Windows: .MOTH-HOME is it too raise OSError(errno.ENOENT, "marker") os.stat(full) # ENOENT / ENOTDIR exactly as the kernel says it, trailing slash included return full def entries(self, full: str, hidden: str) -> list[str]: """hidden: "" (ls), "A" (-A) or "a" (-a). Code point order is C.UTF-8's collation.""" names = os.listdir(full) if os.path.realpath(full) == self.root: names = [x for x in names if x != MARKER] if not hidden: names = [x for x in names if not x.startswith(".")] names.sort() return [".", ".."] + names if hidden == "a" else names @staticmethod def flags(args: list[str], allowed: str) -> tuple[set[str], list[str]]: """GNU-style: options anywhere before `--`, bundled (-1a). Unknown option -> Unsupported.""" opts, paths, done = set(), [], False for a in args: if not done and a == "--": done = True elif not done and a.startswith("-") and len(a) > 1: if a.startswith("--") or any(c not in allowed for c in a[1:]): raise Unsupported(a) opts |= set(a[1:]) else: paths.append(a) return opts, paths # ls --------------------------------------------------------------------------------------------------- def ls(self, args, out, err) -> int: opts, paths = self.flags(args, "1aAR") hidden = "a" if "a" in opts else "A" if "A" in opts else "" code, files, dirs = 0, [], [] for p in paths or ["."]: try: full = self.locate(p) except OSError as e: err.append(f"ls: cannot access {quote(p, True)}: {_REASON.get(e.errno, e.strerror)}\n") code = 2 continue (dirs if os.path.isdir(full) else files).append((p, full)) files.sort() dirs.sort() for p, _ in files: out.append(p.encode() + b"\n") headers = len(paths) > 1 or "R" in opts first = not files queue = list(dirs) while queue: p, full = queue.pop(0) if headers: out.append(("" if first else "\n").encode() + p.encode() + b":\n") first = False try: names = self.entries(full, hidden) except OSError as e: err.append(f"ls: cannot open directory {quote(p, True)}: {_REASON.get(e.errno, e.strerror)}\n") code = max(code, 1) continue out.extend(x.encode("utf-8", "surrogateescape") + b"\n" for x in names) if "R" in opts: subs = [(p.rstrip("/") + "/" + x if p != "/" else "/" + x, os.path.join(full, x)) for x in names if x not in (".", "..") and os.path.isdir(os.path.join(full, x)) and not os.path.islink(os.path.join(full, x))] queue[:0] = subs return code # echo ------------------------------------------------------------------------------------------------- def echo(self, args, out, err) -> int: newline, words = _echo_opts(args) out.append((" ".join(words) + ("\n" if newline else "")).encode("utf-8", "surrogateescape")) return 0 # cat / head / wc -------------------------------------------------------------------------------------- def read(self, p: str) -> bytes: full = self.locate(p) if os.path.isdir(full): raise OSError(errno.EISDIR, "dir") with open(full, "rb") as f: return f.read(4 << 20) def cat(self, args, out, err) -> int: _, paths = self.flags(args, "") code = 0 for p in paths: try: out.append(self.read(p)) except OSError as e: err.append(f"cat: {quote(p, False)}: {_REASON.get(e.errno, e.strerror)}\n") code = 1 return code def head(self, args, out, err) -> int: n, rest, i = 10, [], 0 while i < len(args): # -n N, -nN, -N, --lines=N a = args[i] if a == "-n" and i + 1 < len(args): n, i = _int(args[i + 1]), i + 2 continue m = re.fullmatch(r"-n(-?\d+)|-(\d+)|--lines=(-?\d+)", a) if m: n = int(next(g for g in m.groups() if g is not None)) elif a.startswith("-") and a != "-": raise Unsupported(a) else: rest.append(a) i += 1 code = 0 for k, p in enumerate(rest): try: data = self.read(p) except OSError as e: verb = "error reading" if e.errno == errno.EISDIR else "cannot open" tail = "" if e.errno == errno.EISDIR else " for reading" err.append(f"head: {verb} {quote(p, True)}{tail}: {_REASON.get(e.errno, e.strerror)}\n") code = 1 if e.errno == errno.EISDIR and len(rest) > 1: out.append(("\n" if k else "").encode() + f"==> {p} <==\n".encode()) continue if len(rest) > 1: out.append(("\n" if k else "").encode() + f"==> {p} <==\n".encode()) lines = data.splitlines(keepends=True) out.append(b"".join(lines[:n] if n >= 0 else lines[:max(0, len(lines) + n)])) return code def wc(self, args, out, err) -> int: opts, paths = self.flags(args, "lwcm") if not paths: raise Unsupported("stdin") kinds = [k for k in "lwmc" if k in opts] or ["l", "w", "c"] rows, code, total, size, regular = [], 0, dict.fromkeys(kinds, 0), 0, True for p in paths: try: full = self.locate(p) if os.path.isdir(full): rows.append((p, dict.fromkeys(kinds, 0))) regular = False raise OSError(errno.EISDIR, "dir") data = self.read(p) except OSError as e: rows.append((p, e)) continue size += len(data) text = data.decode("utf-8", "replace") c = {"l": data.count(b"\n"), "w": len(text.split()), "m": len(text), "c": len(data)} rows.append((p, {k: c[k] for k in kinds})) # width: one file + one count -> 1; else digits of the regular files' total size (7 for a non-file) if len(paths) == 1 and len(kinds) == 1: width = 1 else: ok = [r for r in rows if isinstance(r[1], dict)] width = len(str(size)) if ok else 1 if not regular: width = max(width, 7) for p, r in rows: if isinstance(r, OSError): err.append(f"wc: {quote(p, False)}: {_REASON.get(r.errno, r.strerror)}\n") code = 1 continue out.append((" ".join(str(r[k]).rjust(width) for k in kinds) + f" {p}\n").encode()) for k in kinds: total[k] += r[k] if len(paths) > 1: out.append((" ".join(str(total[k]).rjust(width) for k in kinds) + " total\n").encode()) return code _WIN_DEVICES = {"con", "prn", "aux", "nul", "conin$", "conout$"} | {f"{d}{i}" for d in ("com", "lpt") for i in "123456789\u00b9\u00b2\u00b3"} def _windows_unsafe(path: str) -> bool: """On Windows "\\" is a separator, ":" a drive or a stream (C:x, \\\\host\\share, x::$DATA), device names (CON, nul.txt) open devices, and a trailing dot or space is dropped (".moth-home." is the marker). In bash on Linux all of these are plain file names; here none of them may reach the file system.""" if "\\" in path or ":" in path: return True for part in path.split("/"): if part in ("", ".", ".."): continue if part[-1] in ". " or part.split(".")[0].rstrip(" ").lower() in _WIN_DEVICES: return True return False def _echo_opts(args: list[str]) -> tuple[bool, list[str]]: """bash's echo: leading words made only of n/e/E after a dash are options; -e (escapes) is not supported.""" newline, i = True, 0 while i < len(args) and re.fullmatch(r"-[neE]+", args[i]): if "e" in args[i]: raise Unsupported("echo -e") newline = newline and "n" not in args[i] i += 1 return newline, args[i:] def _int(s: str) -> int: try: return int(s) except ValueError: raise Unsupported(s) from None