File size: 16,080 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
"""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