@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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +18 -0
- package/README.md +203 -33
- package/adapters/agent_context.py +216 -29
- package/adapters/agent_transport.py +131 -0
- package/adapters/bash_agent.py +67 -92
- package/adapters/octocode_agent.py +63 -53
- package/adapters/shell_sandbox.py +487 -0
- package/assets/logo.jpg +0 -0
- package/dist/index.js +1287 -412
- package/docs/ARCHITECTURE.md +169 -26
- package/docs/CLI.md +107 -17
- package/docs/CPU_GPU_ROUTING.md +65 -20
- package/docs/ECONOMICS.md +20 -15
- package/docs/INDEX.md +8 -2
- package/docs/MODEL_METADATA.md +106 -0
- package/docs/MODEL_SELECTION.md +15 -2
- package/docs/MONITORING.md +285 -0
- package/docs/OLLAMA_SIDECAR.md +8 -6
- package/docs/PRODUCTION.md +49 -18
- package/docs/PRODUCT_POSITIONING.md +86 -256
- package/docs/README.md +40 -0
- package/docs/SCOPES_AND_WARMING.md +226 -0
- package/docs/TESTING.md +118 -1
- package/docs/dev/FINDINGS_AND_POSITIONING.md +290 -0
- package/docs/dev/LOCAL_PERFORMANCE_TESTING.md +298 -0
- package/docs/{OLLAMA_SYSTEM_OPTIMIZATION.md → dev/OLLAMA_SYSTEM_OPTIMIZATION.md} +19 -18
- package/native/index.d.ts +31 -0
- package/package.json +13 -10
- /package/docs/{ADR_RESOURCE_AWARE_BACKEND_ROUTING.md → dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md} +0 -0
|
@@ -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
|
package/assets/logo.jpg
ADDED
|
Binary file
|