@octocodeai/freellama-mcp-server 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ """Read-only execution of model-written shell commands.
2
+
3
+ `delegate_research` runs the bash adapter against the caller's *real* workspace, so a command is
4
+ untrusted input with the user's privileges. A regex denylist in front of `bash -c` let
5
+ `echo x > README.md`, `rm -rf ./x`, `git checkout -- .` and `cat $(printf "\\057etc\\057hostname")`
6
+ through, and the child inherited every secret in the MCP server's environment.
7
+
8
+ Confinement is layered so that no single check carries the whole guarantee:
9
+
10
+ 1. **Command validation** (always): every pipeline segment must start with an allowlisted
11
+ read-only tool, tool flags that write or execute (``find -delete``, ``sed -i``, ``rg --pre`` ...)
12
+ are refused, and shell features that hide a command or a path from validation (command/process
13
+ substitution, parameter expansion, ANSI-C quoting, subshells, loops) are refused.
14
+ 2. **Restricted bash** (always): ``bash -r`` refuses output redirection, ``cd``, command names with
15
+ a slash, and changing ``PATH``; ``PATH`` holds only symlinks to the allowlisted tools, so
16
+ ``python3``, ``rm`` or ``curl`` do not exist for the command.
17
+ 3. **Scrubbed environment** (always): only locale variables survive; ``HOME`` is the workspace.
18
+ 4. **OS sandbox** (when available): ``bwrap`` on Linux mounts only system directories and the
19
+ workspace read-only with no network; ``sandbox-exec`` on macOS denies writes, network, and reads
20
+ under ``/Users`` and ``/Volumes`` outside the workspace. Each is probed once and skipped if the
21
+ host refuses it (for example a container without user namespaces).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import glob
27
+ import os
28
+ import re
29
+ import shlex
30
+ import shutil
31
+ import subprocess
32
+ import tempfile
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ # Tools that only read. `awk` is absent on purpose: `system()`, `print >` and `cmd | getline` make
37
+ # any awk program a general executor, and mawk/BSD awk have no sandbox flag.
38
+ READ_ONLY_TOOLS = (
39
+ "ls", "find", "grep", "egrep", "fgrep", "rg", "cat", "head", "tail", "wc", "sort", "uniq",
40
+ "cut", "tr", "nl", "file", "stat", "du", "tree", "basename", "dirname", "realpath", "readlink",
41
+ "sed", "jq", "diff", "cmp", "column", "xargs", "git", "tac", "fold", "paste", "comm", "od",
42
+ "strings", "seq", "expr",
43
+ )
44
+ SAFE_BUILTINS = frozenset({"echo", "printf", "true", "false", "test", "[", "pwd"})
45
+ READ_ONLY_GIT = frozenset({
46
+ "log", "show", "grep", "ls-files", "ls-tree", "diff", "blame", "status", "rev-parse",
47
+ "cat-file", "shortlog", "describe", "rev-list", "name-rev", "annotate",
48
+ })
49
+
50
+ # Per-tool flags that write a file or run another program.
51
+ _FORBIDDEN_FLAGS: dict[str, tuple[str, ...]] = {
52
+ "find": ("-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"),
53
+ "sort": ("-o", "--output", "--compress-program", "--files0-from"),
54
+ "wc": ("--files0-from",),
55
+ "grep": ("-f", "--file"),
56
+ "egrep": ("-f", "--file"),
57
+ "fgrep": ("-f", "--file"),
58
+ "rg": ("--pre", "--pre-glob", "--search-zip", "-z", "-f", "--file"),
59
+ "tree": ("-o",),
60
+ "file": ("-C", "--compile", "-f", "--files-from"),
61
+ "jq": ("-f", "--from-file", "-L", "--library-path"),
62
+ "git": ("-C", "-c", "--config-env", "--exec-path", "--output", "-o", "--ext-diff", "--textconv", "--open-files-in-pager", "-O"),
63
+ "diff": ("--to-file",),
64
+ }
65
+ _SED_IN_PLACE = re.compile(r"^(?:-[a-zA-Z]*i|--in-place)")
66
+
67
+ # Shell syntax that would hide a command or a path from validation.
68
+ _HIDDEN_EXPANSION = re.compile(r"\$\(|`|<\(|>\(|\$'|\$\{|\$[A-Za-z_0-9@*#?!$-]")
69
+ _ALLOWED_STDERR_REDIRECT = re.compile(r"(?<![\w>&])2>\s*/dev/null|(?<![\w>&])2>&1")
70
+
71
+ _HOME_ESCAPE = re.compile(r"(?:^|[\s=\"'])(?:~|\$HOME\b|\$\{HOME\})")
72
+ _ABS_PATH = re.compile(r"(?:^|[\s=\"'(:,])(/[^\s\"';|&)]*)")
73
+ _DOTDOT_PATH = re.compile(r"(?:^|[\s=\"'/])\.\.(?:/|[\s\"']|$)")
74
+ _FS_ABS_PREFIX = re.compile(
75
+ r"^/(?:etc|usr|home|Users|var|tmp|private|opt|root|System|Library|bin|sbin|dev|proc|sys|"
76
+ r"Applications|Volumes|mnt|media|srv|run|nix|snap)(?:/|$)"
77
+ )
78
+ _GLOB_CHARS = re.compile(r"[*?\[]")
79
+
80
+
81
+ def _outside_single_quotes(command: str) -> str:
82
+ """The command with single-quoted spans blanked, since bash expands nothing inside them."""
83
+ out: list[str] = []
84
+ quoted = False
85
+ escaped = False
86
+ for char in command:
87
+ if quoted:
88
+ quoted = char != "'"
89
+ out.append(" ")
90
+ continue
91
+ if escaped:
92
+ escaped = False
93
+ out.append(" ")
94
+ continue
95
+ if char == "\\":
96
+ escaped = True
97
+ out.append(" ")
98
+ continue
99
+ if char == "'":
100
+ quoted = True
101
+ # Keep the quote after an unquoted `$` so `$'\057etc'` (ANSI-C quoting) stays visible.
102
+ out.append("'" if out and out[-1] == "$" else " ")
103
+ continue
104
+ out.append(char)
105
+ return "".join(out)
106
+
107
+
108
+ def assert_command_confined(root: Path, command_text: str) -> None:
109
+ """Reject command text that names a path outside the workspace."""
110
+ if _HOME_ESCAPE.search(command_text):
111
+ raise ValueError("command blocked: home-directory path is outside the workspace")
112
+ if _DOTDOT_PATH.search(command_text):
113
+ raise ValueError("command blocked: '..' walks outside the workspace")
114
+ root = root.resolve()
115
+ for match in _ABS_PATH.finditer(command_text):
116
+ raw = match.group(1)
117
+ if _GLOB_CHARS.search(raw):
118
+ # `/et?/hostname` exists only after expansion; judge every match, and treat a
119
+ # pattern that matches nothing inside the workspace as an escape.
120
+ matches = [Path(item) for item in glob.glob(raw)]
121
+ if not matches and not raw.startswith(str(root)):
122
+ raise ValueError(f"command blocked: path pattern escapes workspace: {raw}")
123
+ candidates = matches
124
+ else:
125
+ candidate = Path(raw)
126
+ if _FS_ABS_PREFIX.match(raw) is None and not candidate.exists():
127
+ continue # e.g. a URL path in a grep pattern, not a filesystem path
128
+ candidates = [candidate]
129
+ for candidate in candidates:
130
+ resolved = candidate.resolve()
131
+ if resolved != root and root not in resolved.parents:
132
+ raise ValueError(f"command blocked: path escapes workspace: {raw}")
133
+
134
+
135
+ def _segments(command_text: str) -> list[list[str]]:
136
+ lexer = shlex.shlex(command_text, posix=True, punctuation_chars=";&|()<>")
137
+ lexer.whitespace_split = True
138
+ lexer.commenters = ""
139
+ segments: list[list[str]] = [[]]
140
+ for token in lexer:
141
+ if token in {"|", "||", "&&", ";"}:
142
+ segments.append([])
143
+ elif token and set(token) <= set(";&|()<>"):
144
+ if token == "<":
145
+ segments[-1].append(token)
146
+ continue
147
+ raise ValueError(
148
+ f"command blocked: shell operator {token!r} is not allowed (read-only research: "
149
+ "no output redirection, background jobs or subshells)"
150
+ )
151
+ else:
152
+ segments[-1].append(token)
153
+ return [segment for segment in segments if segment]
154
+
155
+
156
+ def _check_tool(words: list[str]) -> None:
157
+ if not words:
158
+ return
159
+ name = words[0]
160
+ args = words[1:]
161
+ if "/" in name:
162
+ raise ValueError(f"command blocked: run tools by name, not path: {name}")
163
+ if name in SAFE_BUILTINS:
164
+ return
165
+ if name not in READ_ONLY_TOOLS:
166
+ raise ValueError(
167
+ f"command blocked: {name!r} is not a read-only research tool "
168
+ f"(allowed: {', '.join(READ_ONLY_TOOLS)})"
169
+ )
170
+ for flag in _FORBIDDEN_FLAGS.get(name, ()):
171
+ if any(
172
+ arg == flag or arg.startswith(flag + "=")
173
+ or (len(flag) == 2 and flag.startswith("-") and not arg.startswith("--")
174
+ and arg.startswith("-") and flag[1] in arg[1:])
175
+ for arg in args
176
+ ):
177
+ raise ValueError(f"command blocked: `{name} {flag}` loads external arguments, writes or executes")
178
+ if name == "sed":
179
+ for arg in args:
180
+ if _SED_IN_PLACE.match(arg):
181
+ raise ValueError("command blocked: sed -i edits files")
182
+ scripts = _sed_programs(args)
183
+ for script in scripts:
184
+ _check_sed_program(script)
185
+ if name == "git":
186
+ subcommand = next((arg for arg in args if not arg.startswith("-")), None)
187
+ if subcommand not in READ_ONLY_GIT:
188
+ raise ValueError(
189
+ f"command blocked: `git {subcommand}` is not read-only "
190
+ f"(allowed: {', '.join(sorted(READ_ONLY_GIT))})"
191
+ )
192
+ if name == "xargs":
193
+ if any(arg == "--arg-file" or arg.startswith("--arg-file=")
194
+ or (arg.startswith("-a") and not arg.startswith("--")) for arg in args):
195
+ raise ValueError("command blocked: xargs argument files cannot be confined")
196
+ target = _xargs_command(args)
197
+ if target[0] not in {"echo", "printf", "true", "false"}:
198
+ raise ValueError("command blocked: xargs filesystem arguments cannot be confined")
199
+ _check_tool(target)
200
+
201
+
202
+ def _check_sed_program(program: str) -> None:
203
+ """Accept the bounded read-only sed grammar, including addressed substitutions."""
204
+ index = 0
205
+
206
+ def delimited(start: int) -> int:
207
+ delimiter = program[start]
208
+ cursor = start + 1
209
+ while cursor < len(program):
210
+ if program[cursor] == "\\":
211
+ cursor += 2
212
+ elif program[cursor] == delimiter:
213
+ return cursor + 1
214
+ else:
215
+ cursor += 1
216
+ raise ValueError("command blocked: unterminated sed expression")
217
+
218
+ while index < len(program):
219
+ while index < len(program) and (program[index].isspace() or program[index] in ";{}"):
220
+ index += 1
221
+ if index == len(program):
222
+ return
223
+ # An address may be numeric, last-line, or a delimited regular expression.
224
+ for address in range(2):
225
+ if index < len(program) and program[index].isdigit():
226
+ while index < len(program) and program[index].isdigit():
227
+ index += 1
228
+ elif index < len(program) and program[index] == "$":
229
+ index += 1
230
+ elif index < len(program) and program[index] == "/":
231
+ index = delimited(index)
232
+ elif index + 1 < len(program) and program[index] == "\\":
233
+ index = delimited(index + 1)
234
+ else:
235
+ break
236
+ while index < len(program) and program[index].isspace():
237
+ index += 1
238
+ if address == 0 and index < len(program) and program[index] == ",":
239
+ index += 1
240
+ continue
241
+ break
242
+ while index < len(program) and (program[index].isspace() or program[index] == "!"):
243
+ index += 1
244
+ if index == len(program):
245
+ raise ValueError("command blocked: sed command is missing")
246
+ command = program[index]
247
+ index += 1
248
+ if command == "s" and index < len(program):
249
+ index = delimited(index)
250
+ # The replacement shares the first delimiter, so include it in the next scan.
251
+ index = delimited(index - 1)
252
+ flags_start = index
253
+ while index < len(program) and program[index] not in ";}\n":
254
+ index += 1
255
+ flags = program[flags_start:index].strip()
256
+ if any(flag not in "gIp0123456789" for flag in flags):
257
+ raise ValueError("command blocked: sed substitution flags can read, write or execute")
258
+ elif command not in "pPdDqQnNhHgGx={}":
259
+ raise ValueError("command blocked: sed command can read, write or execute files")
260
+ if command not in "{}" and index < len(program) and not (program[index].isspace() or program[index] in ";{}"):
261
+ raise ValueError("command blocked: unsupported sed command argument")
262
+
263
+
264
+ def _sed_programs(args: list[str]) -> list[str]:
265
+ """Inspect every inline program; loading another file would hide its commands."""
266
+ programs: list[str] = []
267
+ index = 0
268
+ while index < len(args):
269
+ arg = args[index]
270
+ if arg == "--":
271
+ programs.extend(args[index + 1:])
272
+ break
273
+ if arg == "--file" or arg.startswith("--file="):
274
+ raise ValueError("command blocked: sed script files can read, write or execute")
275
+ if arg == "--expression":
276
+ index += 1
277
+ if index >= len(args):
278
+ raise ValueError("command blocked: sed expression is missing")
279
+ programs.append(args[index])
280
+ elif arg.startswith("--expression="):
281
+ programs.append(arg.split("=", 1)[1])
282
+ elif arg.startswith("-") and not arg.startswith("--"):
283
+ # n/E/r are no-argument mode switches. e/f end a short-option cluster and
284
+ # consume the remainder (or next argument), so attached programs stay visible.
285
+ short = arg[1:]
286
+ for offset, option in enumerate(short):
287
+ if option == "f":
288
+ raise ValueError("command blocked: sed script files can read, write or execute")
289
+ if option == "e":
290
+ program = short[offset + 1:]
291
+ if not program:
292
+ index += 1
293
+ if index >= len(args):
294
+ raise ValueError("command blocked: sed expression is missing")
295
+ program = args[index]
296
+ programs.append(program)
297
+ break
298
+ if option not in "nEr":
299
+ raise ValueError("command blocked: unsupported sed option")
300
+ elif not arg.startswith("--"):
301
+ if not programs:
302
+ programs.append(arg)
303
+ else:
304
+ raise ValueError("command blocked: unsupported sed option")
305
+ index += 1
306
+ return programs
307
+
308
+
309
+ def _xargs_command(args: list[str]) -> list[str]:
310
+ with_value = {"-n", "-I", "-d", "-P", "-L", "-s", "-E", "-a", "--arg-file", "--delimiter", "--max-args", "--max-procs"}
311
+ index = 0
312
+ while index < len(args) and args[index].startswith("-"):
313
+ index += 2 if args[index] in with_value else 1
314
+ command = args[index:]
315
+ return command or ["echo"]
316
+
317
+
318
+ def validate_command(root: Path, command_text: str) -> str:
319
+ """Return the command to run under restricted bash, or raise ValueError naming the refusal."""
320
+ if not command_text.strip():
321
+ raise ValueError("empty shell command")
322
+ if "\n" in command_text.strip():
323
+ raise ValueError("command blocked: send one command line per turn")
324
+ # stderr is captured together with stdout already, so these two redirects are no-ops that
325
+ # models write out of habit; rbash would refuse them as output redirection.
326
+ command_text = _ALLOWED_STDERR_REDIRECT.sub(" ", command_text).strip()
327
+ unquoted = _outside_single_quotes(command_text)
328
+ if _HIDDEN_EXPANSION.search(unquoted):
329
+ raise ValueError(
330
+ "command blocked: command substitution, process substitution and $-expansion are "
331
+ "not allowed; write paths and patterns literally"
332
+ )
333
+ # Quoted program braces are literal; unquoted brace paths expand before execution.
334
+ brace_tokens = shlex.shlex(command_text, posix=False)
335
+ brace_tokens.whitespace_split = True
336
+ brace_tokens.commenters = ""
337
+ for token in brace_tokens:
338
+ if token.startswith(("'", '"', "--exclude-dir=")):
339
+ continue
340
+ if "{" in token or "}" in token:
341
+ raise ValueError("command blocked: brace-expanded paths cannot be confined")
342
+ for segment in _segments(command_text):
343
+ for word in segment[1:]:
344
+ if word.startswith("--exclude-dir="):
345
+ continue
346
+ if word == "<":
347
+ continue
348
+ if word.startswith("-"):
349
+ if "=" not in word:
350
+ continue
351
+ word = word.split("=", 1)[1]
352
+ candidate = root / word
353
+ candidates = [Path(match) for match in glob.glob(str(candidate))] if _GLOB_CHARS.search(word) else [candidate]
354
+ for candidate in candidates:
355
+ if candidate.exists() or candidate.is_symlink():
356
+ resolved = candidate.resolve()
357
+ if resolved != root.resolve() and root.resolve() not in resolved.parents:
358
+ raise ValueError("command blocked: relative path escapes workspace")
359
+ tool = segment[0]
360
+ follow_flags = {"rg": "L", "grep": "R", "egrep": "R", "fgrep": "R", "find": "L", "tree": "l", "du": "L"}.get(tool)
361
+ if follow_flags and any(
362
+ arg == "--follow" or arg == "--dereference" or arg == "--dereference-recursive"
363
+ or (arg.startswith("-") and not arg.startswith("--") and follow_flags in arg[1:])
364
+ for arg in segment[1:]
365
+ ):
366
+ raise ValueError("command blocked: recursive symlink following cannot be confined")
367
+ if "=" in segment[0] and not segment[0].startswith("="):
368
+ raise ValueError("command blocked: variable assignment is not allowed")
369
+ if segment[0] in {"for", "while", "until", "if", "case", "function", "{", "eval", "exec", "source", "."}:
370
+ raise ValueError(f"command blocked: shell construct {segment[0]!r} is not allowed; use one pipeline")
371
+ _check_tool([word for word in segment if word != "<"])
372
+ assert_command_confined(root, command_text)
373
+ return command_text
374
+
375
+
376
+ def _probe(argv: list[str], env: dict[str, str]) -> bool:
377
+ try:
378
+ return subprocess.run(argv, env=env, capture_output=True, timeout=10, check=False).returncode == 0
379
+ except (OSError, subprocess.TimeoutExpired):
380
+ return False
381
+
382
+
383
+ def _macos_profile(workspace: Path, scratch: Path) -> str:
384
+ def quote(path: Path) -> str:
385
+ return '"' + str(path).replace("\\", "\\\\").replace('"', '\\"') + '"'
386
+
387
+ return f"""(version 1)
388
+ (allow default)
389
+ (deny network*)
390
+ (deny file-write*)
391
+ (allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (subpath {quote(scratch)}))
392
+ (deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/root"))
393
+ (allow file-read-data (subpath {quote(workspace)}) (subpath {quote(scratch)}))
394
+ """
395
+
396
+
397
+ @dataclass
398
+ class ReadOnlyShell:
399
+ """A reusable read-only shell rooted at `workspace`. Use as a context manager."""
400
+
401
+ workspace: Path
402
+ kind: str = "restricted-bash"
403
+ _scratch: tempfile.TemporaryDirectory[str] | None = field(default=None, repr=False)
404
+ _prefix: list[str] = field(default_factory=list, repr=False)
405
+ _env: dict[str, str] = field(default_factory=dict, repr=False)
406
+ _bash: str = "/bin/bash"
407
+
408
+ def __enter__(self) -> "ReadOnlyShell":
409
+ self.workspace = self.workspace.resolve()
410
+ self._scratch = tempfile.TemporaryDirectory(prefix="freellama-shell-")
411
+ scratch = Path(self._scratch.name)
412
+ bin_dir = scratch / "bin"
413
+ bin_dir.mkdir()
414
+ # Resolve tools against the caller's PATH once, before the environment is scrubbed.
415
+ for tool in READ_ONLY_TOOLS:
416
+ located = shutil.which(tool)
417
+ if located:
418
+ (bin_dir / tool).symlink_to(os.path.realpath(located))
419
+ self._bash = shutil.which("bash") or "/bin/bash"
420
+ self._env = {
421
+ key: value
422
+ for key, value in os.environ.items()
423
+ if key in {"LANG", "LC_ALL", "LC_CTYPE", "TZ"}
424
+ }
425
+ self._env.update({
426
+ "PATH": str(bin_dir),
427
+ "HOME": str(self.workspace),
428
+ "TMPDIR": str(scratch),
429
+ "TERM": "dumb",
430
+ "PAGER": "cat",
431
+ "GIT_PAGER": "cat",
432
+ "GIT_CONFIG_NOSYSTEM": "1",
433
+ "GIT_CONFIG_GLOBAL": os.devnull,
434
+ "GIT_OPTIONAL_LOCKS": "0", # `git status` otherwise rewrites the index
435
+ "GIT_TERMINAL_PROMPT": "0",
436
+ })
437
+ self._prefix = self._os_sandbox(scratch, bin_dir)
438
+ return self
439
+
440
+ def __exit__(self, *_exc: object) -> None:
441
+ if self._scratch is not None:
442
+ self._scratch.cleanup()
443
+ self._scratch = None
444
+
445
+ def _os_sandbox(self, scratch: Path, bin_dir: Path) -> list[str]:
446
+ if os.environ.get("FREELLAMA_AGENT_OS_SANDBOX", "auto") == "off":
447
+ return []
448
+ sandbox_exec = shutil.which("sandbox-exec")
449
+ if sandbox_exec:
450
+ profile = scratch / "profile.sb"
451
+ profile.write_text(_macos_profile(self.workspace, scratch), encoding="utf-8")
452
+ prefix = [sandbox_exec, "-f", str(profile)]
453
+ if _probe([*prefix, self._bash, "-c", "true"], self._env):
454
+ self.kind = "sandbox-exec+restricted-bash"
455
+ return prefix
456
+ bwrap = shutil.which("bwrap")
457
+ if bwrap:
458
+ prefix = [bwrap, "--unshare-all", "--die-with-parent", "--new-session",
459
+ "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]
460
+ for system_dir in ("/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc/alternatives",
461
+ "/etc/ld.so.cache", "/etc/localtime", "/nix/store"):
462
+ prefix += ["--ro-bind-try", system_dir, system_dir]
463
+ prefix += ["--ro-bind", str(self.workspace), str(self.workspace),
464
+ "--ro-bind", str(bin_dir), str(bin_dir),
465
+ "--bind", str(scratch), str(scratch),
466
+ "--chdir", str(self.workspace), "--"]
467
+ if _probe([*prefix, self._bash, "-c", "true"], self._env):
468
+ self.kind = "bwrap+restricted-bash"
469
+ return prefix
470
+ return []
471
+
472
+ def run(self, command_text: str, timeout_seconds: float) -> str:
473
+ command = validate_command(self.workspace, command_text)
474
+ result = subprocess.run(
475
+ [*self._prefix, self._bash, "--restricted", "--noprofile", "--norc", "-c", command],
476
+ cwd=self.workspace,
477
+ env=self._env,
478
+ text=True,
479
+ capture_output=True,
480
+ timeout=timeout_seconds,
481
+ check=False,
482
+ stdin=subprocess.DEVNULL,
483
+ )
484
+ output = (result.stdout or "") + (result.stderr or "")
485
+ if not output.strip():
486
+ output = f"(no output, exit code {result.returncode})"
487
+ return output
Binary file