modelfunction-cli 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +74 -2
- package/fixtures/models.json +39 -0
- package/fixtures/suite.json +150 -0
- package/lib/ablate.js +120 -0
- package/lib/adapter-import.js +24 -0
- package/lib/adapters/mock.js +143 -0
- package/lib/adapters/openai.js +95 -0
- package/lib/cases.js +188 -0
- package/lib/compare.js +220 -0
- package/lib/eval-exec.js +140 -0
- package/lib/exec.js +265 -0
- package/lib/fingerprint.js +52 -0
- package/lib/fsutil.js +7 -0
- package/lib/lint.js +110 -0
- package/lib/mcp-probe.js +152 -0
- package/lib/mcp-server.js +216 -0
- package/lib/report.js +119 -0
- package/lib/runtime/harness.mjs +145 -0
- package/lib/runtime/harness.py +373 -0
- package/lib/sandbox-check.js +166 -0
- package/lib/scan.js +510 -0
- package/lib/score.js +137 -0
- package/lib/suite.js +117 -0
- package/mf.js +636 -0
- package/package.json +38 -4
- package/skills/mf-cases/SKILL.md +58 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import tracemalloc
|
|
8
|
+
import traceback
|
|
9
|
+
import inspect
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_args(argv):
|
|
13
|
+
opts = {
|
|
14
|
+
"entry": None,
|
|
15
|
+
"input": None,
|
|
16
|
+
"export": "run",
|
|
17
|
+
"result": None,
|
|
18
|
+
"warmup": 0,
|
|
19
|
+
"allow_read": [],
|
|
20
|
+
"allow_write": [],
|
|
21
|
+
"allow_net": [],
|
|
22
|
+
}
|
|
23
|
+
i = 0
|
|
24
|
+
while i < len(argv):
|
|
25
|
+
arg = argv[i]
|
|
26
|
+
if arg in (
|
|
27
|
+
"--entry",
|
|
28
|
+
"--input",
|
|
29
|
+
"--export",
|
|
30
|
+
"--result",
|
|
31
|
+
"--warmup",
|
|
32
|
+
"--allow-read",
|
|
33
|
+
"--allow-write",
|
|
34
|
+
"--allow-net",
|
|
35
|
+
):
|
|
36
|
+
if i + 1 >= len(argv):
|
|
37
|
+
raise SystemExit("missing value for %s" % arg)
|
|
38
|
+
value = argv[i + 1]
|
|
39
|
+
i += 1
|
|
40
|
+
if arg == "--entry":
|
|
41
|
+
opts["entry"] = value
|
|
42
|
+
elif arg == "--input":
|
|
43
|
+
opts["input"] = value
|
|
44
|
+
elif arg == "--export":
|
|
45
|
+
opts["export"] = value
|
|
46
|
+
elif arg == "--result":
|
|
47
|
+
opts["result"] = value
|
|
48
|
+
elif arg == "--warmup":
|
|
49
|
+
opts["warmup"] = int(value or 0)
|
|
50
|
+
elif arg == "--allow-read":
|
|
51
|
+
opts["allow_read"].append(value)
|
|
52
|
+
elif arg == "--allow-write":
|
|
53
|
+
opts["allow_write"].append(value)
|
|
54
|
+
elif arg == "--allow-net":
|
|
55
|
+
opts["allow_net"].append(value)
|
|
56
|
+
i += 1
|
|
57
|
+
return opts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def peak_rss_mb():
|
|
61
|
+
try:
|
|
62
|
+
import resource
|
|
63
|
+
|
|
64
|
+
value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
65
|
+
return round(value / (1048576.0 if sys.platform == "darwin" else 1024.0), 1)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
try:
|
|
70
|
+
import ctypes
|
|
71
|
+
from ctypes import wintypes
|
|
72
|
+
|
|
73
|
+
class ProcessMemoryCounters(ctypes.Structure):
|
|
74
|
+
_fields_ = [
|
|
75
|
+
("cb", wintypes.DWORD),
|
|
76
|
+
("PageFaultCount", wintypes.DWORD),
|
|
77
|
+
("PeakWorkingSetSize", ctypes.c_size_t),
|
|
78
|
+
("WorkingSetSize", ctypes.c_size_t),
|
|
79
|
+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
|
80
|
+
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
|
81
|
+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
|
82
|
+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
|
83
|
+
("PagefileUsage", ctypes.c_size_t),
|
|
84
|
+
("PeakPagefileUsage", ctypes.c_size_t),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
kernel32 = ctypes.windll.kernel32
|
|
88
|
+
psapi = ctypes.windll.psapi
|
|
89
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
90
|
+
psapi.GetProcessMemoryInfo.argtypes = [
|
|
91
|
+
wintypes.HANDLE,
|
|
92
|
+
ctypes.POINTER(ProcessMemoryCounters),
|
|
93
|
+
wintypes.DWORD,
|
|
94
|
+
]
|
|
95
|
+
psapi.GetProcessMemoryInfo.restype = wintypes.BOOL
|
|
96
|
+
counters = ProcessMemoryCounters()
|
|
97
|
+
counters.cb = ctypes.sizeof(ProcessMemoryCounters)
|
|
98
|
+
psapi.GetProcessMemoryInfo(kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb)
|
|
99
|
+
return round(counters.PeakWorkingSetSize / 1048576.0, 1)
|
|
100
|
+
except Exception:
|
|
101
|
+
return None
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize(path):
|
|
106
|
+
try:
|
|
107
|
+
return os.path.normcase(os.path.realpath(os.path.abspath(path)))
|
|
108
|
+
except Exception:
|
|
109
|
+
return os.path.normcase(os.path.abspath(path))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def under(path, roots):
|
|
113
|
+
for root in roots:
|
|
114
|
+
if path == root or path.startswith(root + os.sep):
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def host_allowed(host, allowed):
|
|
120
|
+
if host is None:
|
|
121
|
+
return False
|
|
122
|
+
name = str(host).lower()
|
|
123
|
+
for pattern in allowed:
|
|
124
|
+
candidate = pattern.lower()
|
|
125
|
+
if candidate.startswith("*."):
|
|
126
|
+
candidate = candidate[2:]
|
|
127
|
+
if name == candidate or name.endswith("." + candidate):
|
|
128
|
+
return True
|
|
129
|
+
elif name == candidate:
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def install_audit_hook(allowed_read, allowed_write, allowed_net):
|
|
135
|
+
def hook(event, args):
|
|
136
|
+
if event == "open":
|
|
137
|
+
path = args[0]
|
|
138
|
+
mode = args[1] if len(args) > 1 else ""
|
|
139
|
+
if isinstance(path, int):
|
|
140
|
+
return
|
|
141
|
+
resolved = normalize(str(path))
|
|
142
|
+
writing = any(flag in (mode or "") for flag in "wax+")
|
|
143
|
+
if writing:
|
|
144
|
+
if not under(resolved, allowed_write):
|
|
145
|
+
raise PermissionError("mf-blocked: write %s" % resolved)
|
|
146
|
+
elif not under(resolved, allowed_read):
|
|
147
|
+
raise PermissionError("mf-blocked: read %s" % resolved)
|
|
148
|
+
elif event == "socket.connect":
|
|
149
|
+
address = args[1] if len(args) > 1 else None
|
|
150
|
+
host = address[0] if isinstance(address, tuple) and address else None
|
|
151
|
+
if not host_allowed(host, allowed_net):
|
|
152
|
+
raise PermissionError("mf-blocked: connect %s" % host)
|
|
153
|
+
elif event in ("socket.getaddrinfo", "socket.sendto", "socket.bind"):
|
|
154
|
+
host = args[0] if args else None
|
|
155
|
+
if isinstance(host, tuple):
|
|
156
|
+
host = host[0] if host else None
|
|
157
|
+
if not host_allowed(host, allowed_net):
|
|
158
|
+
raise PermissionError("mf-blocked: net %s" % event)
|
|
159
|
+
elif event in ("subprocess.Popen", "os.system", "os.exec", "os.fork", "os.forkpty", "os.spawn"):
|
|
160
|
+
raise PermissionError("mf-blocked: %s" % event)
|
|
161
|
+
elif event == "ctypes.dlopen":
|
|
162
|
+
raise PermissionError("mf-blocked: ctypes.dlopen")
|
|
163
|
+
|
|
164
|
+
sys.addaudithook(hook)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def emit(payload, result_path):
|
|
168
|
+
text = json.dumps(payload, ensure_ascii=False, default=str)
|
|
169
|
+
if result_path:
|
|
170
|
+
try:
|
|
171
|
+
with open(result_path, "w", encoding="utf-8") as handle:
|
|
172
|
+
handle.write(text)
|
|
173
|
+
return
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
sys.stdout.write("__MF_RESULT__" + text + "\n")
|
|
177
|
+
sys.stdout.flush()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main():
|
|
181
|
+
opts = parse_args(sys.argv[1:])
|
|
182
|
+
entry = opts["entry"]
|
|
183
|
+
input_path = opts["input"]
|
|
184
|
+
result_path = opts["result"]
|
|
185
|
+
|
|
186
|
+
if not entry or not input_path:
|
|
187
|
+
emit({"ok": False, "error": {"name": "HarnessError", "message": "--entry and --input are required"}}, result_path)
|
|
188
|
+
return 2
|
|
189
|
+
|
|
190
|
+
start_rss = peak_rss_mb()
|
|
191
|
+
harness_dir = os.path.dirname(os.path.abspath(__file__))
|
|
192
|
+
entry_dir = os.path.dirname(os.path.abspath(entry))
|
|
193
|
+
input_dir = os.path.dirname(os.path.abspath(input_path))
|
|
194
|
+
cwd = os.getcwd()
|
|
195
|
+
prefix_dirs = [normalize(value) for value in (sys.prefix, getattr(sys, "base_prefix", sys.prefix))]
|
|
196
|
+
|
|
197
|
+
allowed_read = [normalize(value) for value in [harness_dir, entry_dir, input_dir, cwd] + opts["allow_read"]]
|
|
198
|
+
allowed_read.extend(prefix_dirs)
|
|
199
|
+
allowed_write = [
|
|
200
|
+
normalize(value)
|
|
201
|
+
for value in ([os.path.dirname(os.path.abspath(result_path))] if result_path else []) + opts["allow_write"]
|
|
202
|
+
]
|
|
203
|
+
allowed_net = list(opts["allow_net"])
|
|
204
|
+
|
|
205
|
+
with open(input_path, "r", encoding="utf-8") as handle:
|
|
206
|
+
input_data = json.load(handle)
|
|
207
|
+
|
|
208
|
+
install_audit_hook(allowed_read, allowed_write, allowed_net)
|
|
209
|
+
|
|
210
|
+
if entry_dir not in sys.path:
|
|
211
|
+
sys.path.insert(0, entry_dir)
|
|
212
|
+
|
|
213
|
+
import_started = time.perf_counter()
|
|
214
|
+
try:
|
|
215
|
+
spec = importlib.util.spec_from_file_location("mf_tool_under_test", entry)
|
|
216
|
+
module = importlib.util.module_from_spec(spec)
|
|
217
|
+
spec.loader.exec_module(module)
|
|
218
|
+
function = (
|
|
219
|
+
getattr(module, opts["export"], None)
|
|
220
|
+
or getattr(module, "main", None)
|
|
221
|
+
or getattr(module, "default", None)
|
|
222
|
+
)
|
|
223
|
+
if not callable(function):
|
|
224
|
+
emit(
|
|
225
|
+
{
|
|
226
|
+
"ok": False,
|
|
227
|
+
"error": {
|
|
228
|
+
"name": "HarnessError",
|
|
229
|
+
"message": "%s 需要导出 %s / main / default 函数" % (entry, opts["export"]),
|
|
230
|
+
},
|
|
231
|
+
"metrics": {"importMs": round((time.perf_counter() - import_started) * 1000, 2)},
|
|
232
|
+
},
|
|
233
|
+
result_path,
|
|
234
|
+
)
|
|
235
|
+
return 2
|
|
236
|
+
except PermissionError as error:
|
|
237
|
+
emit(
|
|
238
|
+
{
|
|
239
|
+
"ok": False,
|
|
240
|
+
"permissionDenied": True,
|
|
241
|
+
"error": {
|
|
242
|
+
"name": "PermissionError",
|
|
243
|
+
"code": "MF_PERMISSION_DENIED",
|
|
244
|
+
"message": str(error),
|
|
245
|
+
"phase": "import",
|
|
246
|
+
},
|
|
247
|
+
"metrics": {"importMs": round((time.perf_counter() - import_started) * 1000, 2)},
|
|
248
|
+
},
|
|
249
|
+
result_path,
|
|
250
|
+
)
|
|
251
|
+
return 3
|
|
252
|
+
except Exception as error:
|
|
253
|
+
emit(
|
|
254
|
+
{
|
|
255
|
+
"ok": False,
|
|
256
|
+
"error": {
|
|
257
|
+
"name": type(error).__name__,
|
|
258
|
+
"message": str(error),
|
|
259
|
+
"traceback": traceback.format_exc(),
|
|
260
|
+
"phase": "import",
|
|
261
|
+
},
|
|
262
|
+
"metrics": {"importMs": round((time.perf_counter() - import_started) * 1000, 2)},
|
|
263
|
+
},
|
|
264
|
+
result_path,
|
|
265
|
+
)
|
|
266
|
+
return 1
|
|
267
|
+
import_ms = (time.perf_counter() - import_started) * 1000
|
|
268
|
+
|
|
269
|
+
warmup_error = None
|
|
270
|
+
for _ in range(opts["warmup"]):
|
|
271
|
+
try:
|
|
272
|
+
outcome = function(input_data)
|
|
273
|
+
if inspect.isawaitable(outcome):
|
|
274
|
+
asyncio.run(outcome)
|
|
275
|
+
except Exception as error: # noqa: BLE001
|
|
276
|
+
warmup_error = error
|
|
277
|
+
break
|
|
278
|
+
if warmup_error is not None:
|
|
279
|
+
emit(
|
|
280
|
+
{
|
|
281
|
+
"ok": False,
|
|
282
|
+
"error": {
|
|
283
|
+
"name": type(warmup_error).__name__,
|
|
284
|
+
"message": str(warmup_error),
|
|
285
|
+
"traceback": traceback.format_exc(),
|
|
286
|
+
"phase": "warmup",
|
|
287
|
+
},
|
|
288
|
+
"metrics": {"importMs": round(import_ms, 2)},
|
|
289
|
+
},
|
|
290
|
+
result_path,
|
|
291
|
+
)
|
|
292
|
+
return 1
|
|
293
|
+
|
|
294
|
+
tracemalloc.start()
|
|
295
|
+
cpu_started = time.process_time()
|
|
296
|
+
call_started = time.perf_counter()
|
|
297
|
+
try:
|
|
298
|
+
outcome = function(input_data)
|
|
299
|
+
if inspect.isawaitable(outcome):
|
|
300
|
+
outcome = asyncio.run(outcome)
|
|
301
|
+
duration_ms = (time.perf_counter() - call_started) * 1000
|
|
302
|
+
cpu_ms = (time.process_time() - cpu_started) * 1000
|
|
303
|
+
_, heap_peak = tracemalloc.get_traced_memory()
|
|
304
|
+
tracemalloc.stop()
|
|
305
|
+
emit(
|
|
306
|
+
{
|
|
307
|
+
"ok": True,
|
|
308
|
+
"result": outcome,
|
|
309
|
+
"metrics": {
|
|
310
|
+
"durationMs": round(duration_ms, 2),
|
|
311
|
+
"importMs": round(import_ms, 2),
|
|
312
|
+
"cpuMs": round(cpu_ms, 2),
|
|
313
|
+
"heapPeakMB": round(heap_peak / 1048576.0, 2),
|
|
314
|
+
"startRssMB": start_rss,
|
|
315
|
+
"peakRssMB": peak_rss_mb(),
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
result_path,
|
|
319
|
+
)
|
|
320
|
+
return 0
|
|
321
|
+
except PermissionError as error:
|
|
322
|
+
duration_ms = (time.perf_counter() - call_started) * 1000
|
|
323
|
+
cpu_ms = (time.process_time() - cpu_started) * 1000
|
|
324
|
+
tracemalloc.stop()
|
|
325
|
+
emit(
|
|
326
|
+
{
|
|
327
|
+
"ok": False,
|
|
328
|
+
"permissionDenied": True,
|
|
329
|
+
"error": {
|
|
330
|
+
"name": "PermissionError",
|
|
331
|
+
"code": "MF_PERMISSION_DENIED",
|
|
332
|
+
"message": str(error),
|
|
333
|
+
"phase": "call",
|
|
334
|
+
},
|
|
335
|
+
"metrics": {
|
|
336
|
+
"durationMs": round(duration_ms, 2),
|
|
337
|
+
"importMs": round(import_ms, 2),
|
|
338
|
+
"cpuMs": round(cpu_ms, 2),
|
|
339
|
+
"startRssMB": start_rss,
|
|
340
|
+
"peakRssMB": peak_rss_mb(),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
result_path,
|
|
344
|
+
)
|
|
345
|
+
return 3
|
|
346
|
+
except Exception as error:
|
|
347
|
+
duration_ms = (time.perf_counter() - call_started) * 1000
|
|
348
|
+
cpu_ms = (time.process_time() - cpu_started) * 1000
|
|
349
|
+
tracemalloc.stop()
|
|
350
|
+
emit(
|
|
351
|
+
{
|
|
352
|
+
"ok": False,
|
|
353
|
+
"error": {
|
|
354
|
+
"name": type(error).__name__,
|
|
355
|
+
"message": str(error),
|
|
356
|
+
"traceback": traceback.format_exc(),
|
|
357
|
+
"phase": "call",
|
|
358
|
+
},
|
|
359
|
+
"metrics": {
|
|
360
|
+
"durationMs": round(duration_ms, 2),
|
|
361
|
+
"importMs": round(import_ms, 2),
|
|
362
|
+
"cpuMs": round(cpu_ms, 2),
|
|
363
|
+
"startRssMB": start_rss,
|
|
364
|
+
"peakRssMB": peak_rss_mb(),
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
result_path,
|
|
368
|
+
)
|
|
369
|
+
return 1
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
sys.exit(main())
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function run(command, args, timeout = 4000) {
|
|
4
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout, windowsHide: true });
|
|
5
|
+
return {
|
|
6
|
+
ok: result.status === 0,
|
|
7
|
+
status: result.status,
|
|
8
|
+
error: result.error?.code ?? null,
|
|
9
|
+
stdout: (result.stdout ?? "").trim(),
|
|
10
|
+
stderr: (result.stderr ?? "").trim()
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function versionOf(result) {
|
|
15
|
+
const line = `${result.stdout}\n${result.stderr}`.trim().split(/\r?\n/)[0];
|
|
16
|
+
return line || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkSandbox() {
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
const nodeVersion = process.versions.node;
|
|
22
|
+
const nodeMajor = Number(nodeVersion.split(".")[0]);
|
|
23
|
+
const permission = run(process.execPath, ["--permission", "-e", "0"]);
|
|
24
|
+
|
|
25
|
+
const pythonProbe = platform === "win32" ? run("python", ["--version"]) : run("python3", ["--version"]);
|
|
26
|
+
const pythonFallback = pythonProbe.ok ? pythonProbe : run("python", ["--version"]);
|
|
27
|
+
const pythonOk = pythonProbe.ok || pythonFallback.ok;
|
|
28
|
+
|
|
29
|
+
const bwrapVersion = platform === "linux" ? run("bwrap", ["--version"]) : { ok: false };
|
|
30
|
+
const bwrapFunctional =
|
|
31
|
+
bwrapVersion.ok &&
|
|
32
|
+
run("bwrap", ["--unshare-user", "--unshare-net", "--ro-bind", "/", "/", "/bin/true"]).ok;
|
|
33
|
+
|
|
34
|
+
const seatbelt =
|
|
35
|
+
platform === "darwin"
|
|
36
|
+
? run("/usr/bin/sandbox-exec", ["-p", "(version 1)(allow default)", "/bin/echo", "ok"])
|
|
37
|
+
: { ok: false };
|
|
38
|
+
|
|
39
|
+
const docker = run("docker", ["version", "--format", "{{.Server.Version}}"]);
|
|
40
|
+
|
|
41
|
+
let wslInfo = { ok: false, distro: null, bwrap: false };
|
|
42
|
+
if (platform === "win32") {
|
|
43
|
+
const wslList = run("wsl", ["-l", "-q"]);
|
|
44
|
+
if (wslList.ok) {
|
|
45
|
+
const distroProbe = run("wsl", ["-e", "sh", "-c", "echo $WSL_DISTRO_NAME"]);
|
|
46
|
+
const bwrapProbe = run("wsl", [
|
|
47
|
+
"-e",
|
|
48
|
+
"sh",
|
|
49
|
+
"-c",
|
|
50
|
+
"command -v bwrap >/dev/null && bwrap --unshare-user --unshare-net --ro-bind / / /bin/true && echo ok"
|
|
51
|
+
]);
|
|
52
|
+
wslInfo = {
|
|
53
|
+
ok: true,
|
|
54
|
+
distro: distroProbe.stdout.trim() || null,
|
|
55
|
+
bwrap: /(^|\n)ok(\n|$)/.test(bwrapProbe.stdout)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const engines = [
|
|
61
|
+
{
|
|
62
|
+
name: "node-permission",
|
|
63
|
+
available: permission.ok,
|
|
64
|
+
version: `node ${nodeVersion}`,
|
|
65
|
+
canBlock: { fs: true, net: nodeMajor >= 25, child: true },
|
|
66
|
+
note:
|
|
67
|
+
nodeMajor >= 25
|
|
68
|
+
? "仅 Node 工具;能力限制,不是安全边界"
|
|
69
|
+
: "仅 Node 工具;网络控制需 Node >= 25,当前版本不限制网络"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "python-audit",
|
|
73
|
+
available: pythonOk,
|
|
74
|
+
version: versionOf(pythonFallback) ?? null,
|
|
75
|
+
canBlock: { fs: true, net: true, child: true },
|
|
76
|
+
note: "audit hook 能力限制,可被 ctypes 等方式绕过;不是安全边界"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "bwrap",
|
|
80
|
+
available: Boolean(bwrapFunctional),
|
|
81
|
+
version: versionOf(bwrapVersion) ?? null,
|
|
82
|
+
canBlock: { fs: true, net: true, child: true },
|
|
83
|
+
note:
|
|
84
|
+
bwrapVersion.ok && !bwrapFunctional
|
|
85
|
+
? "已安装但不可用(非特权 userns 可能被 AppArmor 限制)"
|
|
86
|
+
: "Linux 命名空间隔离"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "sandbox-exec",
|
|
90
|
+
available: Boolean(seatbelt.ok),
|
|
91
|
+
version: seatbelt.ok ? "system" : null,
|
|
92
|
+
canBlock: { fs: true, net: true, child: true },
|
|
93
|
+
note: "macOS 自带;Apple 已标记废弃"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "docker",
|
|
97
|
+
available: Boolean(docker.ok),
|
|
98
|
+
version: docker.stdout || null,
|
|
99
|
+
canBlock: { fs: true, net: true, child: true },
|
|
100
|
+
note: docker.ok ? "容器隔离;需要镜像与守护进程" : "未安装或守护进程未运行"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "wsl2",
|
|
104
|
+
available: wslInfo.ok,
|
|
105
|
+
version: wslInfo.distro,
|
|
106
|
+
canBlock: { fs: false, net: false, child: false },
|
|
107
|
+
note: wslInfo.ok ? "WSL2 已启用;本身不是隔离引擎" : "未启用"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "wsl2-bwrap",
|
|
111
|
+
available: wslInfo.ok && wslInfo.bwrap,
|
|
112
|
+
version: wslInfo.distro,
|
|
113
|
+
canBlock: { fs: true, net: true, child: true },
|
|
114
|
+
note:
|
|
115
|
+
wslInfo.ok && !wslInfo.bwrap
|
|
116
|
+
? "WSL2 内没有可用的 bwrap:需要安装 Linux 发行版并在其中 apt install bubblewrap"
|
|
117
|
+
: "通过 WSL2 内的 bwrap 提供强隔离"
|
|
118
|
+
}
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const nodeEngine = engines.find((engine) => engine.name === "node-permission" && engine.available);
|
|
122
|
+
const pythonEngine = engines.find((engine) => engine.name === "python-audit" && engine.available);
|
|
123
|
+
const strongEngine = engines.find(
|
|
124
|
+
(engine) => ["bwrap", "sandbox-exec", "docker", "wsl2-bwrap"].includes(engine.name) && engine.available
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
platform,
|
|
129
|
+
node: { version: nodeVersion, permission: permission.ok, networkControl: nodeMajor >= 25 },
|
|
130
|
+
engines,
|
|
131
|
+
recommendation: {
|
|
132
|
+
nodeTools: nodeEngine ? nodeEngine.name : null,
|
|
133
|
+
pythonTools: pythonEngine ? pythonEngine.name : null,
|
|
134
|
+
strongIsolation: strongEngine ? strongEngine.name : null,
|
|
135
|
+
note: strongEngine
|
|
136
|
+
? "强隔离引擎可用;能力限制模型(node-permission / python-audit)仍不是安全边界"
|
|
137
|
+
: "默认用能力限制模式即可(无需安装);只有当你要执行不可信代码时才需要强隔离——优先选远程沙箱(零本地安装),其次 WSL2/Docker"
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatSandboxReport(report) {
|
|
143
|
+
const lines = [`sandbox check(${report.platform},node ${report.node.version})`, "", "engines:"];
|
|
144
|
+
for (const engine of report.engines) {
|
|
145
|
+
if (!engine.available) {
|
|
146
|
+
lines.push(` ${engine.name.padEnd(16)} n/a`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const caps = [
|
|
150
|
+
engine.canBlock.fs ? "fs" : null,
|
|
151
|
+
engine.canBlock.net ? "net" : null,
|
|
152
|
+
engine.canBlock.child ? "child" : null
|
|
153
|
+
]
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.join("/");
|
|
156
|
+
lines.push(` ${engine.name.padEnd(16)} ok ${caps} ${engine.version ?? ""}`);
|
|
157
|
+
if (engine.note) lines.push(` ${" ".repeat(18)}${engine.note}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.push("", "recommendation:");
|
|
161
|
+
lines.push(` Node 工具: ${report.recommendation.nodeTools ?? "不可用"}`);
|
|
162
|
+
lines.push(` Python 工具: ${report.recommendation.pythonTools ?? "不可用"}`);
|
|
163
|
+
lines.push(` 强隔离: ${report.recommendation.strongIsolation ?? "不可用"}`);
|
|
164
|
+
lines.push(` ${report.recommendation.note}`);
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|