@octocodeai/freellama-mcp-server 0.5.0 → 0.6.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/CONFIGURATION.md +108 -0
- package/DEVELOPING.md +109 -0
- package/README.md +130 -282
- package/dist/index.js +1509 -1411
- package/dist/research/bash-agent.js +2435 -0
- package/dist/research/octocode-agent.js +1639 -0
- package/docs/ARCHITECTURE.md +145 -81
- package/docs/CLI.md +83 -55
- package/docs/CPU_GPU_ROUTING.md +4 -3
- package/docs/MODEL_SELECTION.md +115 -5
- package/docs/MONITORING.md +19 -11
- package/docs/PRODUCTION.md +10 -13
- package/docs/README.md +9 -13
- package/docs/SCOPES_AND_WARMING.md +8 -9
- package/docs/TESTING.md +18 -30
- package/native/index.d.ts +179 -174
- package/native/index.js +3 -3
- package/package.json +11 -10
- package/adapters/agent_context.py +0 -850
- package/adapters/agent_transport.py +0 -215
- package/adapters/bash_agent.py +0 -345
- package/adapters/octocode_agent.py +0 -404
- package/adapters/shell_sandbox.py +0 -487
- package/docs/ECONOMICS.md +0 -110
- package/docs/INDEX.md +0 -21
- package/docs/MODEL_METADATA.md +0 -106
- package/docs/OLLAMA_SIDECAR.md +0 -78
- package/docs/PRODUCT_POSITIONING.md +0 -121
- package/docs/dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md +0 -55
- package/docs/dev/FINDINGS_AND_POSITIONING.md +0 -290
- package/docs/dev/LOCAL_PERFORMANCE_TESTING.md +0 -298
- package/docs/dev/OLLAMA_SYSTEM_OPTIMIZATION.md +0 -255
|
@@ -1,487 +0,0 @@
|
|
|
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/docs/ECONOMICS.md
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
# Token economics
|
|
2
|
-
|
|
3
|
-
Offloading can keep source material and intermediate tool output out of the calling agent's
|
|
4
|
-
context. FreeLlama returns compact results while the local model performs the work. This shifts
|
|
5
|
-
computation to local hardware; it does not eliminate model tokens, power use, or waiting time.
|
|
6
|
-
|
|
7
|
-
The per-call rows come from one machine (M4 Pro, 48 GB unified memory). Values marked with `~`
|
|
8
|
-
and the session projection are estimates derived from those calls.
|
|
9
|
-
|
|
10
|
-
## Per-call, measured
|
|
11
|
-
|
|
12
|
-
| Work | Source tokens | Returned tokens | Preserved |
|
|
13
|
-
|---|---:|---:|---:|
|
|
14
|
-
| 6 grounded code questions | 59,208 | 1,742 | **97.1%** |
|
|
15
|
-
| 4 text embeddings (307 ms) | 17,600 | ~200 | **98.9%** |
|
|
16
|
-
| 1 image, OCR, byte-exact | 1,970 | 37 | **98.1%** |
|
|
17
|
-
| Historical tool-schema surface, per turn | <3.2k | 0 | **100%** |
|
|
18
|
-
|
|
19
|
-
## MCP-envelope audit (2026-09-03)
|
|
20
|
-
|
|
21
|
-
The earlier table describes a historical workload on an M4 Pro. This dated audit measured the
|
|
22
|
-
eight-tool MCP build available on September 3, 2026. JSON tokenization varies by client and model, so it records
|
|
23
|
-
bytes first and gives `characters / 4` only as a deliberately rough token estimate.
|
|
24
|
-
|
|
25
|
-
| Audited result | Canonical structured chars | Compact text chars | Duplicate chars avoided | Rough duplicate-token estimate |
|
|
26
|
-
|---|---:|---:|---:|---:|
|
|
27
|
-
| `doctor {view:"summary"}` | 3,099 | 35 | 3,064 | ~766 |
|
|
28
|
-
| `doctor {view:"scheduler"}` | 3,933 | 35 | 3,898 | ~975 |
|
|
29
|
-
| `doctor {view:"config"}` | 7,373 | 86 | 7,287 | ~1,822 |
|
|
30
|
-
| `doctor {view:"full"}` | 10,836 | 185 | 10,651 | ~2,663 |
|
|
31
|
-
| `models {view:"raw", limit:1}` | 447 | 43 | 404 | ~101 |
|
|
32
|
-
|
|
33
|
-
These are **not automatically billed-token savings**. They are the second serialized text copy
|
|
34
|
-
that FreeLlama does not emit. An MCP host that feeds both `structuredContent` and text into a model
|
|
35
|
-
can avoid roughly the listed amount; a host that already keeps only one representation does not.
|
|
36
|
-
The canonical structured payload still exists for deterministic clients that need it.
|
|
37
|
-
|
|
38
|
-
The integration suite bounds the complete tool schema and initialization instructions using
|
|
39
|
-
the same rough token conversion. Read the
|
|
40
|
-
[protocol context-budget contract](../packages/mcp/test/integration/protocol.test.ts) for the
|
|
41
|
-
enforced ceiling.
|
|
42
|
-
This is tool-schema **rent**, not a saving: clients pay it whenever they expose
|
|
43
|
-
the entire tool list to a model. Read the bundled documentation resource on demand and do not expose
|
|
44
|
-
unneeded tools in a client that supports tool filtering.
|
|
45
|
-
|
|
46
|
-
Delegated research has a different economics boundary. It keeps source files and full tool
|
|
47
|
-
observations out of the calling agent's active prompt, but it spends local Ollama input/output
|
|
48
|
-
tokens in the delegated agent. The historical research rows below remain useful workload evidence;
|
|
49
|
-
they are not a claim that the current installed model or host can reproduce those savings. Measure
|
|
50
|
-
the same question, source size, model, context policy, and client transcript before using a number
|
|
51
|
-
as a production budget.
|
|
52
|
-
|
|
53
|
-
## Scaled to a session
|
|
54
|
-
|
|
55
|
-
20 grounded questions, 200 turns, one embedding index over 322 chunks:
|
|
56
|
-
|
|
57
|
-
| Component | Tokens preserved |
|
|
58
|
-
|---|---:|
|
|
59
|
-
| Research offload | 191,553 |
|
|
60
|
-
| Embedding vectors withheld | 354,200 |
|
|
61
|
-
| Schema rent avoided, after ~90% prompt-cache discount | 59,740 |
|
|
62
|
-
| **Total** | **≈ 605,000 — about 3 × a 200K window** |
|
|
63
|
-
|
|
64
|
-
## Measurement and projection limits
|
|
65
|
-
|
|
66
|
-
Schema rent is nominally **597,400** tokens (200 turns × the former eight-tool schemas). Including
|
|
67
|
-
the nominal figure puts the headline at **1.14M — nearly double**. This report discounts it by ~90%
|
|
68
|
-
because prompt caching makes cached input far cheaper,
|
|
69
|
-
and because the rent only accrues at all if a *separate* small model holds the tool schemas. The
|
|
70
|
-
first two rows are the uncontestable part: that data physically never reaches the orchestrator.
|
|
71
|
-
|
|
72
|
-
**Do not lead with this number.** Providers, caching strategies, context windows, and pricing
|
|
73
|
-
change. The durable benefit is context isolation: delegated research returns an answer and
|
|
74
|
-
citations in place of its full local transcript, and embedding responses omit vectors by default.
|
|
75
|
-
Caller-requested output and exposed tool schemas still enter the client's context. Measure that
|
|
76
|
-
client's actual transcript before claiming a token or billing reduction.
|
|
77
|
-
|
|
78
|
-
## The cost side
|
|
79
|
-
|
|
80
|
-
| | |
|
|
81
|
-
|---|---|
|
|
82
|
-
| Grounded question latency | **7–62 s** vs ~1 s for a frontier model that already holds the file |
|
|
83
|
-
| Where it goes | **51% prompt re-evaluation**, 42% generation, 7% model load (measured on a 4-turn run) |
|
|
84
|
-
| Scaling | turn-dominated: `seconds ≈ 9.8 × tool_calls`, correlation **0.811** over 60 runs |
|
|
85
|
-
| Break-even | past ~1k tokens of source the token maths wins; below that, read the file directly |
|
|
86
|
-
| Judgment ceiling | ~67% accurate, in the same confident tone as the ~99% case |
|
|
87
|
-
|
|
88
|
-
**Correction (measured same day): prefix-cache reuse WORKS on this path.** An earlier revision of
|
|
89
|
-
this file claimed the opposite by misreading `prompt_eval_count`, which counts *all* prompt tokens
|
|
90
|
-
whether or not they were served from cache. The durations tell the truth:
|
|
91
|
-
|
|
92
|
-
| Probe (raw `/api/chat`, 2,462-token prefix) | prompt_eval |
|
|
93
|
-
|---|---:|
|
|
94
|
-
| turn 1, cold | 18,631 ms |
|
|
95
|
-
| turn 2, warm — tiny suffix appended | **281 ms** |
|
|
96
|
-
| a *different* conversation interjected | 22,467 ms (its own cold prefill) |
|
|
97
|
-
| turn 3 of the original, after the interjection | **285 ms — cache survived** |
|
|
98
|
-
|
|
99
|
-
So the per-turn cost is **prefill of NEW tokens plus generation**, not re-reading old context, and
|
|
100
|
-
the cache even survives interleaved conversations. The `seconds ≈ 9.8 × tool_calls` relationship
|
|
101
|
-
still holds empirically — each turn adds a fresh observation to prefill (~130 tok/s measured) and a
|
|
102
|
-
generation (~40 tok/s) — but the mechanism is new-token cost, not context re-evaluation. Fewer,
|
|
103
|
-
better-aimed commands remain the lever; "the cache is broken" is not the reason.
|
|
104
|
-
|
|
105
|
-
**One consequence worth acting on: context compaction is cache-hostile.** `fit_to_context` rewrites
|
|
106
|
-
old observations when the window is nearly full, which changes the byte prefix and invalidates the
|
|
107
|
-
cache from that point — the next turn re-prefills everything after the edit. The cheap mitigation is
|
|
108
|
-
headroom: `OLLAMA_KV_CACHE_TYPE=q8_0` roughly halves KV memory (flash attention is already auto-on,
|
|
109
|
-
measured and documented in `doctor`), which buys double the context for the same memory and makes
|
|
110
|
-
compaction — and therefore invalidation — rare.
|
package/docs/INDEX.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# FreeLlama packaged documentation
|
|
2
|
-
|
|
3
|
-
Read only the document relevant to the current operation. Repository `docs/` is the source of truth.
|
|
4
|
-
|
|
5
|
-
- [FreeLlama architecture](ARCHITECTURE.md)
|
|
6
|
-
- [CLI reference](CLI.md)
|
|
7
|
-
- [Run models on CPU and GPU](CPU_GPU_ROUTING.md)
|
|
8
|
-
- [Token economics](ECONOMICS.md)
|
|
9
|
-
- [Ollama model metadata](MODEL_METADATA.md)
|
|
10
|
-
- [Choose local models](MODEL_SELECTION.md)
|
|
11
|
-
- [Runtime status and live tuning](MONITORING.md)
|
|
12
|
-
- [Ollama sidecar boundary](OLLAMA_SIDECAR.md)
|
|
13
|
-
- [Prepare FreeLlama for production](PRODUCTION.md)
|
|
14
|
-
- [Product positioning](PRODUCT_POSITIONING.md)
|
|
15
|
-
- [FreeLlama documentation](README.md)
|
|
16
|
-
- [Scope history and model warming](SCOPES_AND_WARMING.md)
|
|
17
|
-
- [Test FreeLlama](TESTING.md)
|
|
18
|
-
- [ADR: Resource-aware backend routing](dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md)
|
|
19
|
-
- [FreeLlama findings and open-source comparison](dev/FINDINGS_AND_POSITIONING.md)
|
|
20
|
-
- [Test throughput, CPU/GPU activity, and thermal behavior on macOS](dev/LOCAL_PERFORMANCE_TESTING.md)
|
|
21
|
-
- [Ollama and FreeLlama optimization boundary](dev/OLLAMA_SYSTEM_OPTIMIZATION.md)
|