frutebox-node 0.1.0__py3-none-any.whl

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,718 @@
1
+ """The node's ENGINE GUARD — the single chokepoint between an inbound job and the
2
+ model engine running on somebody's real machine.
3
+
4
+ Why this exists
5
+ ---------------
6
+ A provider node is an ordinary laptop, desktop or server owned by a volunteer or a
7
+ customer. It accepts work from a gateway on behalf of *any* signed-up user. The job
8
+ frame is therefore fully attacker-controlled data: model name, message list, tool
9
+ schemas, generation flags. Before this module, that data was copied more or less
10
+ verbatim into an HTTP request against a local engine daemon (Ollama / llama.cpp) that
11
+ has **no authentication at all** and a full model-management API.
12
+
13
+ Everything a job can influence now passes through here first.
14
+
15
+ WHAT IS CONTAINED (enforced by this module + its callers)
16
+ ---------------------------------------------------------
17
+ * **Engine surface.** Only the chat/generate/embed endpoints are reachable. The
18
+ model-management endpoints (``/api/pull``, ``/api/create``, ``/api/delete``,
19
+ ``/api/copy``, ``/api/push``, ``/api/blobs``) and llama.cpp's ``/slots``
20
+ (prompt-cache save/restore to disk) are refused *by path* — no job-handling code
21
+ can construct a URL for them, and a future caller that tries gets an exception
22
+ rather than a silent request.
23
+ * **Model choice.** A job may only ask for a model the node explicitly advertises.
24
+ Anything else is rejected with an error the gateway can re-plan on, instead of
25
+ being handed to an engine that would go and *download* it.
26
+ * **Message shape.** Only ``role``/``content``/``name``/``tool_call_id`` (plus
27
+ sanitised assistant ``tool_calls``) survive. Unknown fields are dropped — this is
28
+ what stops Ollama-specific field injection such as ``images``, and any future
29
+ field the engine learns to honour. Non-text content parts are flattened to text.
30
+ * **Prompt size.** Bounded well below the 8 MB WebSocket frame limit — and the bound
31
+ counts *everything that leaves for the engine*, not just ``content``:
32
+ ``tool_calls[].function.arguments`` (itself truncated), ``name`` and
33
+ ``tool_call_id`` all count, because each of them was a way past the cap.
34
+ * **Output modality.** A node only generates what it advertises
35
+ (``output_modalities``). The job frame names a modality; ``provider.py`` refuses one
36
+ this machine never offered, instead of loading SDXL on a text-only laptop.
37
+ * **Tool schemas.** Bounded in count, nesting depth and serialised size; function
38
+ names must match a strict charset; every other key is dropped before the schema
39
+ reaches a chat template (llama.cpp runs with ``--jinja``).
40
+ * **Tool EXECUTION.** Never happens on an untrusted node — see
41
+ ``assert_tool_execution_allowed``. Off by default even on a trusted one.
42
+ * **Generation budget.** Explicit ``num_ctx``/``num_predict`` (Ollama sent none at
43
+ all before, so generation ran until the context filled) and a hard byte ceiling on
44
+ accumulated output that applies whatever the engine does with those hints.
45
+ * **Engine location.** The engine must be on loopback. A misconfigured or hostile
46
+ ``OLLAMA_HOST``/``STEVAI_LLAMACPP_HOST`` cannot turn the node into a proxy that
47
+ posts user prompts to a remote host.
48
+ * **Environment.** Helpers here build a MINIMAL environment for any child process
49
+ the node starts, so cloud credentials sitting in the owner's shell
50
+ (``AWS_*``, ``GOOGLE_APPLICATION_CREDENTIALS``, ``GITHUB_TOKEN``, …) are not
51
+ inherited by the engine or by anything parsing untrusted bytes.
52
+ * **Node-private directories.** Model store and scratch space live under the node's
53
+ own directory (``$STEVAI_WORKSPACE`` when the supervisor provides one — this is
54
+ its first real consumer), not the global temp dir and not the owner's personal
55
+ ``~/.ollama``.
56
+
57
+ WHAT IS **NOT** CONTAINED (read this before claiming "sandbox")
58
+ ---------------------------------------------------------------
59
+ This is defence in depth at the *process* level. It is NOT a VM and NOT an OS
60
+ sandbox. Specifically, still true after this module:
61
+
62
+ * The node process and the engine daemon run **unconfined as the logged-in user**.
63
+ No namespaces, no seccomp, no chroot, no cgroups, no rlimits. A remote-code-
64
+ execution bug in the engine, in torch, or in a media codec gives the attacker the
65
+ user's account — this module only narrows the *inputs* that reach those parsers.
66
+ * We do not start or supervise the engine daemon on most installs; a shell script
67
+ or Electron does. If that daemon was started by hand with the owner's environment
68
+ and their personal model store, the posture helpers here were never applied.
69
+ ``minimal_env()`` only protects children **we** spawn.
70
+ * Nothing here verifies the engine BINARY. An unverified download is still an
71
+ unverified download; guarding its inputs does not make it trustworthy.
72
+ * Memory, CPU share and disk are not capped by the OS. Concurrency and wall-clock
73
+ limits (``provider.py``) bound how much work a job can start, not how much RAM a
74
+ single model load takes.
75
+ * A node that pulls a model at install time still egresses to the model registry.
76
+ * On the desktop app the node still runs inside the Electron main process, so a
77
+ job-handling bug remains an app-level compromise, not just a job failure.
78
+
79
+ Real containment for sensitive work is a *placement* decision (operator-run sealed
80
+ Linux nodes), not something a consumer machine can honestly claim.
81
+ """
82
+ from __future__ import annotations
83
+
84
+ import ipaddress
85
+ import json
86
+ import os
87
+ import re
88
+ import stat
89
+ from contextlib import asynccontextmanager
90
+ from pathlib import Path
91
+ from typing import Any, Dict, Iterable, List, Optional
92
+ from urllib.parse import urlsplit
93
+
94
+ import httpx
95
+
96
+ from .config import CONFIG
97
+ from .envvars import env as envvar
98
+ from .envvars import config_dir
99
+
100
+
101
+ class EngineGuardError(RuntimeError):
102
+ """A job asked the engine for something this node will not do.
103
+
104
+ Raised (never swallowed) so the node reports an error frame and the gateway
105
+ re-plans, instead of the request quietly happening anyway.
106
+ """
107
+
108
+
109
+ # --------------------------------------------------------------------------
110
+ # 1. Engine endpoint allowlist
111
+ # --------------------------------------------------------------------------
112
+ # Inference endpoints. This is the whole reachable surface for job-handling code.
113
+ CHAT_OLLAMA = "/api/chat"
114
+ GENERATE_OLLAMA = "/api/generate"
115
+ EMBED_OLLAMA = "/api/embed"
116
+ CHAT_OPENAI = "/v1/chat/completions"
117
+ COMPLETIONS_OPENAI = "/v1/completions"
118
+ EMBEDDINGS_OPENAI = "/v1/embeddings"
119
+
120
+ ALLOWED_ENDPOINTS = frozenset({
121
+ CHAT_OLLAMA, GENERATE_OLLAMA, EMBED_OLLAMA,
122
+ "/api/embeddings", # Ollama's older embedding path
123
+ CHAT_OPENAI, COMPLETIONS_OPENAI, EMBEDDINGS_OPENAI,
124
+ })
125
+
126
+ # Model-management and engine-admin endpoints. Listed EXPLICITLY (rather than relying
127
+ # on "not in the allowlist") so the ban is testable and so widening ALLOWED_ENDPOINTS
128
+ # by accident cannot re-open them. `/api/pull` is the dangerous one: an engine with no
129
+ # auth will happily download and install any model a caller names.
130
+ FORBIDDEN_ENDPOINTS = frozenset({
131
+ "/api/pull", "/api/push", "/api/create", "/api/delete", "/api/copy", "/api/blobs",
132
+ "/api/show", "/api/tags", "/api/ps", "/api/version",
133
+ "/v1/models",
134
+ # llama.cpp server admin surface. /slots can persist and restore prompt caches to
135
+ # disk paths the caller chooses; /props leaks the host's serving config.
136
+ "/slots", "/props", "/completion", "/infill", "/tokenize", "/detokenize",
137
+ "/lora-adapters", "/metrics", "/health",
138
+ })
139
+
140
+ _TRUSTED_LABELS = ("owned", "trusted")
141
+
142
+
143
+ def _normalise_base(base: str) -> str:
144
+ """Scheme + host + port of the engine, with any path/query the caller smuggled in
145
+ thrown away, and the host required to be loopback."""
146
+ raw = (base or "").strip()
147
+ if not raw:
148
+ raise EngineGuardError("no engine host configured")
149
+ if "://" not in raw:
150
+ raw = f"http://{raw}" # Ollama's "127.0.0.1:11434" convention
151
+ parts = urlsplit(raw)
152
+ if parts.scheme not in ("http", "https"):
153
+ raise EngineGuardError(f"engine host must be http(s), got {parts.scheme!r}")
154
+ host = connect_host(parts.hostname or "")
155
+ if not host:
156
+ raise EngineGuardError("engine host has no hostname")
157
+ if not CONFIG.node_engine_allow_remote and not _is_loopback(host):
158
+ raise EngineGuardError(
159
+ f"engine host {host!r} is not loopback — a node must not send prompts off-box. "
160
+ "Set STEVAI_NODE_ENGINE_ALLOW_REMOTE=1 only if the operator runs the engine "
161
+ "on a private link they control.")
162
+ port = f":{parts.port}" if parts.port else ""
163
+ # `parts.hostname` strips the brackets off an IPv6 literal, so they have to go back
164
+ # on — "http://::1:8080/api/chat" is not a parseable URL and every job would fail
165
+ # transport-side on a node whose engine is addressed as [::1].
166
+ if ":" in host:
167
+ host = f"[{host}]"
168
+ return f"{parts.scheme}://{host}{port}"
169
+
170
+
171
+ #: A BIND address meaning "every interface". As a CONNECT address it is not a destination —
172
+ #: it means "this host", i.e. loopback. Containers routinely set OLLAMA_HOST=0.0.0.0 so the
173
+ #: engine listens broadly, and the node then read that same value back as the address to dial.
174
+ #: Verified live 2026-08-21: the warm node registered, was placed, and every job died with
175
+ #: "engine host '0.0.0.0' is not loopback" — the guard was right, the address was wrong.
176
+ _ANY_ADDRESS = {"0.0.0.0", "::", "[::]"}
177
+
178
+
179
+ def connect_host(host: str) -> str:
180
+ """The address to DIAL for an engine configured with `host`.
181
+
182
+ A wildcard bind address is rewritten to loopback. This is a translation, not a
183
+ relaxation: 0.0.0.0 was never a reachable destination, and the alternative — telling
184
+ operators to set a second variable to the same engine on the same box — is a footgun that
185
+ ends with someone setting ALLOW_REMOTE to silence it.
186
+ """
187
+ h = (host or "").strip()
188
+ return "127.0.0.1" if h in _ANY_ADDRESS else h
189
+
190
+
191
+ def _is_loopback(host: str) -> bool:
192
+ if host.lower() in ("localhost", "localhost.localdomain"):
193
+ return True
194
+ try:
195
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
196
+ except ValueError:
197
+ return False
198
+
199
+
200
+ def engine_url(base: str, path: str) -> str:
201
+ """Build the ONE URL a caller is allowed to hit, or raise.
202
+
203
+ Rejects traversal, absolute URLs, protocol-relative paths, query strings and any
204
+ endpoint outside ``ALLOWED_ENDPOINTS``.
205
+ """
206
+ p = (path or "").strip()
207
+ if not p.startswith("/") or p.startswith("//") or "://" in p or ".." in p:
208
+ raise EngineGuardError(f"illegal engine path {path!r}")
209
+ if "?" in p or "#" in p:
210
+ raise EngineGuardError(f"engine path may not carry a query or fragment: {path!r}")
211
+ canonical = p.rstrip("/") or "/"
212
+ if canonical in FORBIDDEN_ENDPOINTS:
213
+ raise EngineGuardError(
214
+ f"engine endpoint {canonical!r} is model management / admin and is never "
215
+ "reachable from job handling")
216
+ if canonical not in ALLOWED_ENDPOINTS:
217
+ raise EngineGuardError(f"engine endpoint {canonical!r} is not on the node's allowlist")
218
+ return f"{_normalise_base(base)}{canonical}"
219
+
220
+
221
+ # --------------------------------------------------------------------------
222
+ # 2. Model allowlist — a job may only name a model this node actually advertises
223
+ # --------------------------------------------------------------------------
224
+ _allowed_models: "Optional[set]" = None
225
+
226
+
227
+ def arm_models(models: Iterable[str]) -> None:
228
+ """Pin the models this node will ask its engine for.
229
+
230
+ Called by the provider agent at startup with what it advertises to the directory.
231
+ Both the catalog id and the concrete engine tag are accepted, because the gateway
232
+ may legitimately send either. An empty/blank list leaves the guard DISARMED
233
+ (permissive) — that is the gateway/test context, where there is no node to protect.
234
+
235
+ That permissive default is load-bearing for the gateway and a foot-gun on a node:
236
+ a set-but-EMPTY ``$STEVAI_OLLAMA_MODEL`` used to reach here as ``[""]`` and turn
237
+ ``check_model`` into a process-wide pass-through. ``provider.py`` therefore checks
238
+ ``models_armed()`` after calling this and REFUSES TO START if it is False. Any new
239
+ caller that guards a real engine must do the same — do not assume arming worked.
240
+ """
241
+ global _allowed_models
242
+ from .manifest import OLLAMA_TAGS
243
+
244
+ vals: set = set()
245
+ for m in models or []:
246
+ name = str(m or "").strip()
247
+ if not name:
248
+ continue
249
+ vals.add(name)
250
+ vals.add(OLLAMA_TAGS.get(name, name))
251
+ _allowed_models = vals or None
252
+
253
+
254
+ def disarm_models() -> None:
255
+ """Drop the allowlist (tests, and the gateway-side import of `inference`)."""
256
+ global _allowed_models
257
+ _allowed_models = None
258
+
259
+
260
+ def models_armed() -> bool:
261
+ return _allowed_models is not None
262
+
263
+
264
+ def allowed_models() -> List[str]:
265
+ return sorted(_allowed_models or ())
266
+
267
+
268
+ def check_model(requested: str, resolved: str) -> str:
269
+ """Return `resolved` if this node serves it, else raise.
270
+
271
+ Rejecting is the point: an unguarded engine treats "model I don't have" as
272
+ "model I should go and fetch", which is remote-controlled disk + bandwidth use
273
+ on somebody else's machine.
274
+ """
275
+ if _allowed_models is None:
276
+ return resolved
277
+ if resolved in _allowed_models or (requested or "") in _allowed_models:
278
+ return resolved
279
+ raise EngineGuardError(
280
+ f"model {requested!r} is not served by this node "
281
+ f"(serves: {', '.join(allowed_models()) or 'nothing'})")
282
+
283
+
284
+ # --------------------------------------------------------------------------
285
+ # 3. Message / tool sanitisation
286
+ # --------------------------------------------------------------------------
287
+ # "function" is the deprecated OpenAI spelling of "tool"; accepted so an older
288
+ # OpenAI-compatible client is not broken by the guard.
289
+ _ROLES = ("system", "developer", "user", "assistant", "tool", "function")
290
+ _TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$")
291
+ _MAX_TOOL_DEPTH = 8
292
+ # Ceiling on ONE recorded tool call's `arguments`. Real arguments are a small JSON
293
+ # object; this only bites on abuse. Without it, `arguments` was the one field in a
294
+ # message that was copied through at *any* length — see the accounting in
295
+ # `sanitize_messages`.
296
+ MAX_TOOL_ARG_BYTES = 8192
297
+
298
+
299
+ def _content_to_text(content: Any) -> str:
300
+ """Text of a message. A list of parts is flattened to its text parts ONLY — the
301
+ non-text parts (image_url, input_audio, arbitrary dicts) are deliberately dropped
302
+ rather than forwarded, so a caller cannot smuggle bytes into the engine through a
303
+ content part."""
304
+ if content is None:
305
+ return ""
306
+ if isinstance(content, str):
307
+ return content
308
+ if isinstance(content, list):
309
+ parts = []
310
+ for p in content:
311
+ if isinstance(p, str):
312
+ parts.append(p)
313
+ elif isinstance(p, dict) and isinstance(p.get("text"), str):
314
+ parts.append(p["text"])
315
+ return " ".join(x for x in parts if x)
316
+ raise EngineGuardError("message content must be text (or text parts)")
317
+
318
+
319
+ def _sanitize_tool_calls(calls: Any) -> List[Dict]:
320
+ """Assistant tool-call history, reduced to id/type/function{name,arguments}."""
321
+ out: List[Dict] = []
322
+ if not isinstance(calls, list):
323
+ return out
324
+ for c in calls[:CONFIG.node_max_tools]:
325
+ if not isinstance(c, dict):
326
+ continue
327
+ fn = c.get("function")
328
+ if not isinstance(fn, dict):
329
+ continue
330
+ name = str(fn.get("name") or "")
331
+ if not _TOOL_NAME_RE.match(name):
332
+ raise EngineGuardError(f"illegal tool name in history: {name!r}")
333
+ args = fn.get("arguments")
334
+ if isinstance(args, dict):
335
+ encoded = json.dumps(args, default=str).encode("utf-8", "ignore")
336
+ if len(encoded) > MAX_TOOL_ARG_BYTES:
337
+ # Over-cap: degrade the object to truncated text rather than forward
338
+ # megabytes of it. Shape is preserved for the honest (small) case.
339
+ args = encoded[:MAX_TOOL_ARG_BYTES].decode("utf-8", "ignore")
340
+ elif isinstance(args, str):
341
+ raw = args.encode("utf-8", "ignore")
342
+ if len(raw) > MAX_TOOL_ARG_BYTES:
343
+ args = raw[:MAX_TOOL_ARG_BYTES].decode("utf-8", "ignore")
344
+ else:
345
+ args = ""
346
+ clean: Dict = {"type": "function", "function": {"name": name, "arguments": args}}
347
+ if isinstance(c.get("id"), str):
348
+ clean["id"] = c["id"][:128]
349
+ out.append(clean)
350
+ return out
351
+
352
+
353
+ def sanitize_messages(messages: Any) -> List[Dict]:
354
+ """Strip every field the engine did not need to see, and bound the prompt.
355
+
356
+ This is the field-injection kill: whatever the API client (or a compromised
357
+ gateway) attached to a message, only the four chat fields survive.
358
+ """
359
+ if not isinstance(messages, list):
360
+ raise EngineGuardError("messages must be a list")
361
+ if len(messages) > CONFIG.node_max_messages:
362
+ raise EngineGuardError(
363
+ f"too many messages ({len(messages)} > {CONFIG.node_max_messages})")
364
+
365
+ out: List[Dict] = []
366
+ total = 0
367
+ for m in messages:
368
+ if not isinstance(m, dict):
369
+ raise EngineGuardError("each message must be an object")
370
+ role = str(m.get("role") or "user")
371
+ if role not in _ROLES:
372
+ raise EngineGuardError(f"unknown message role {role!r}")
373
+ clean: Dict = {"role": role, "content": _content_to_text(m.get("content"))}
374
+ name = m.get("name")
375
+ if isinstance(name, str) and name:
376
+ clean["name"] = name[:128]
377
+ tcid = m.get("tool_call_id")
378
+ if isinstance(tcid, str) and tcid:
379
+ clean["tool_call_id"] = tcid[:128]
380
+ if role == "assistant" and m.get("tool_calls"):
381
+ tcs = _sanitize_tool_calls(m.get("tool_calls"))
382
+ if tcs:
383
+ clean["tool_calls"] = tcs
384
+ # COUNT EVERY BYTE THAT ACTUALLY LEAVES FOR THE ENGINE, not just `content`.
385
+ # Counting content alone left `tool_calls[].function.arguments` (and `name` /
386
+ # `tool_call_id`) entirely outside the cap: one legal 8 MB WebSocket frame
387
+ # carried 7.5 MB of tool-call arguments past a 1 MB documented limit, and
388
+ # unconstrained by the frame the same shape reached gigabytes.
389
+ total += len(clean["content"].encode("utf-8", "ignore")) + 32
390
+ total += len(clean.get("name", "")) + len(clean.get("tool_call_id", ""))
391
+ if clean.get("tool_calls"):
392
+ total += len(json.dumps(clean["tool_calls"],
393
+ default=str).encode("utf-8", "ignore"))
394
+ if total > CONFIG.node_max_prompt_bytes:
395
+ raise EngineGuardError(
396
+ f"prompt exceeds this node's {CONFIG.node_max_prompt_bytes} byte limit")
397
+ out.append(clean)
398
+ return out
399
+
400
+
401
+ def _depth_ok(obj: Any, depth: int = 0) -> bool:
402
+ if depth > _MAX_TOOL_DEPTH:
403
+ return False
404
+ if isinstance(obj, dict):
405
+ return all(_depth_ok(v, depth + 1) for v in obj.values())
406
+ if isinstance(obj, list):
407
+ return all(_depth_ok(v, depth + 1) for v in obj)
408
+ return True
409
+
410
+
411
+ def sanitize_tools(tools: Any, *, tool_choice: Any = None) -> "Optional[List[Dict]]":
412
+ """Bound and scrub tool DEFINITIONS before they reach a chat template.
413
+
414
+ Returns None when there is nothing to send — including when the owner has turned
415
+ definition passthrough off (``STEVAI_NODE_ALLOW_TOOL_DEFS=0``) and when
416
+ ``tool_choice`` is "none", which is enforced here by withholding the tools
417
+ entirely rather than trusting the engine to honour a flag.
418
+
419
+ NOTE: forwarding a definition is not executing anything. Nothing on the node runs
420
+ a tool — see ``assert_tool_execution_allowed``.
421
+ """
422
+ if tool_choice == "none" or not tools:
423
+ return None
424
+ if not CONFIG.node_allow_tool_defs:
425
+ return None
426
+ if not isinstance(tools, list):
427
+ raise EngineGuardError("tools must be a list")
428
+ if len(tools) > CONFIG.node_max_tools:
429
+ raise EngineGuardError(f"too many tools ({len(tools)} > {CONFIG.node_max_tools})")
430
+
431
+ out: List[Dict] = []
432
+ for t in tools:
433
+ if not isinstance(t, dict):
434
+ raise EngineGuardError("each tool must be an object")
435
+ if str(t.get("type") or "function") != "function":
436
+ raise EngineGuardError(f"unsupported tool type {t.get('type')!r}")
437
+ fn = t.get("function")
438
+ if not isinstance(fn, dict):
439
+ raise EngineGuardError("tool.function must be an object")
440
+ name = str(fn.get("name") or "")
441
+ if not _TOOL_NAME_RE.match(name):
442
+ raise EngineGuardError(f"illegal tool name {name!r}")
443
+ clean_fn: Dict = {"name": name}
444
+ desc = fn.get("description")
445
+ if isinstance(desc, str) and desc:
446
+ clean_fn["description"] = desc[:1024]
447
+ params = fn.get("parameters")
448
+ if params is not None:
449
+ if not isinstance(params, dict):
450
+ raise EngineGuardError(f"tool {name!r}: parameters must be an object")
451
+ if not _depth_ok(params):
452
+ raise EngineGuardError(f"tool {name!r}: parameter schema nests too deeply")
453
+ clean_fn["parameters"] = params
454
+ out.append({"type": "function", "function": clean_fn})
455
+
456
+ size = len(json.dumps(out, default=str).encode("utf-8", "ignore"))
457
+ if size > CONFIG.node_max_tool_bytes:
458
+ raise EngineGuardError(
459
+ f"tool schemas exceed this node's {CONFIG.node_max_tool_bytes} byte limit")
460
+ return out or None
461
+
462
+
463
+ def sanitize_tool_choice(tool_choice: Any) -> Any:
464
+ """Normalise tool_choice to the small set of shapes an engine may be told about."""
465
+ if tool_choice is None:
466
+ return None
467
+ if isinstance(tool_choice, str):
468
+ if tool_choice in ("none", "auto", "required"):
469
+ return tool_choice
470
+ raise EngineGuardError(f"unknown tool_choice {tool_choice!r}")
471
+ if isinstance(tool_choice, dict):
472
+ fn = tool_choice.get("function")
473
+ name = str((fn or {}).get("name") or "") if isinstance(fn, dict) else ""
474
+ if not _TOOL_NAME_RE.match(name):
475
+ raise EngineGuardError("tool_choice must name a valid function")
476
+ return {"type": "function", "function": {"name": name}}
477
+ raise EngineGuardError("tool_choice must be a string or a function selector")
478
+
479
+
480
+ # --------------------------------------------------------------------------
481
+ # 4. Node-side tool EXECUTION — trust-gated, off by default, absent today
482
+ # --------------------------------------------------------------------------
483
+ def tool_execution_allowed(trust: str) -> bool:
484
+ """May THIS node run a tool the model asked for, on the host?
485
+
486
+ Two independent conditions, both required:
487
+ * the operator labelled this node ``owned``/``trusted`` (never self-declared —
488
+ the label arrives from the gateway, which got it from directory policy), and
489
+ * the machine's owner opted in (``STEVAI_NODE_ALLOW_TOOL_EXEC=1``).
490
+
491
+ An untrusted consumer node can therefore never be talked into running anything,
492
+ whatever the env says. Today nothing on the node executes tools at all: the model's
493
+ ``tool_calls`` are returned to the API client, which owns the executor. This gate
494
+ exists so that seam cannot be crossed by accident later.
495
+ """
496
+ return bool(CONFIG.node_allow_tool_exec) and (trust or "untrusted") in _TRUSTED_LABELS
497
+
498
+
499
+ def assert_tool_execution_allowed(trust: str, tool_name: str = "") -> None:
500
+ if not tool_execution_allowed(trust):
501
+ raise EngineGuardError(
502
+ f"this node will not execute host-side tools"
503
+ f"{f' (requested: {tool_name!r})' if tool_name else ''}: "
504
+ f"trust={trust or 'untrusted'}, "
505
+ f"owner opt-in={'on' if CONFIG.node_allow_tool_exec else 'off'}")
506
+
507
+
508
+ # --------------------------------------------------------------------------
509
+ # 5. Generation budget
510
+ # --------------------------------------------------------------------------
511
+ def ollama_options() -> Dict[str, int]:
512
+ """Explicit generation bounds for Ollama. Before this the payload carried NO
513
+ options at all, so a job generated until the context window filled."""
514
+ return {
515
+ "num_ctx": int(CONFIG.node_max_context_tokens),
516
+ "num_predict": int(CONFIG.node_max_output_tokens),
517
+ }
518
+
519
+
520
+ def sanitize_think(value: object) -> "Optional[bool]":
521
+ """Normalise the job's thinking hint to a strict bool, or None to leave it to the model.
522
+
523
+ MEASURED, 2026-08-21: a reasoning model spent ~20 seconds of GPU time — real electricity,
524
+ which is what we bill on — producing hundreds of thinking tokens before answering "PONG".
525
+ On turns that need no reasoning that is pure waste, paid for by the user and the operator
526
+ and felt as a slow answer.
527
+
528
+ Ollama's `think` is silently ignored by models that do not reason, so sending it is safe
529
+ across a mixed fleet. It is normalised here rather than passed through because it arrives
530
+ in a JOB, and a job is caller-controlled input: anything but a real bool is discarded.
531
+ """
532
+ return value if isinstance(value, bool) else None
533
+
534
+
535
+ def sanitize_think_effort(value: object) -> str:
536
+ """Normalise the job's effort tier to one of `thinkbudget.TIERS`, or "" for "not stated".
537
+
538
+ AN ALLOW-LIST, like every other field that arrives in a JOB. `sanitize_think` next door takes a
539
+ strict bool for the same reason: a job is caller-controlled input, and a node that accepted an
540
+ arbitrary string here would forward it to the engine as a reasoning level. "" means the node
541
+ applies no cap and asks for no level, which is exactly today's behaviour — so a gateway that has
542
+ not been redeployed is unaffected.
543
+ """
544
+ from . import thinkbudget
545
+ return value if isinstance(value, str) and value in thinkbudget.TIERS else ""
546
+
547
+
548
+ #: Reasoning budgets are stated in TOKENS (that is what a model spends and what we bill), and the
549
+ #: cap is enforced over CHARACTERS, because a delta is text and no tokeniser is available on the
550
+ #: streaming path. Four is the usual English ratio and the conversion is deliberately generous: a
551
+ #: cap that fired early would truncate reasoning a turn had legitimately earned.
552
+ CHARS_PER_TOKEN = 4
553
+
554
+
555
+ def reasoning_char_budget(effort: str) -> "Optional[int]":
556
+ """The character cap for this tier, or None for unbounded/not-stated."""
557
+ from . import thinkbudget
558
+ if not effort:
559
+ return None
560
+ toks = thinkbudget.max_reasoning_tokens(effort)
561
+ return None if toks is None else toks * CHARS_PER_TOKEN
562
+
563
+
564
+ def llamacpp_max_tokens(requested: int = 0) -> int:
565
+ """Output budget for a llama.cpp turn, clamped to the node's ceiling. Reasoning
566
+ models legitimately need a large budget, which is why this ceiling is separate
567
+ from the Ollama one."""
568
+ ceiling = int(CONFIG.node_max_output_tokens_llamacpp)
569
+ want = int(requested or 0) or ceiling
570
+ return max(1, min(want, ceiling))
571
+
572
+
573
+ class OutputMeter:
574
+ """Hard byte ceiling on what one job may produce, independent of the engine.
575
+
576
+ ``num_predict``/``max_tokens`` are hints an engine may ignore or mis-apply; this
577
+ is the backstop that actually stops a runaway generation from filling a
578
+ volunteer's RAM and the 8 MB result frame.
579
+ """
580
+
581
+ def __init__(self, max_bytes: "Optional[int]" = None) -> None:
582
+ self.max_bytes = int(max_bytes or CONFIG.node_max_output_bytes)
583
+ self.total = 0
584
+
585
+ def add(self, text: str) -> str:
586
+ self.total += len((text or "").encode("utf-8", "ignore"))
587
+ if self.total > self.max_bytes:
588
+ raise EngineGuardError(
589
+ f"engine output exceeded this node's {self.max_bytes} byte limit — job cancelled")
590
+ return text
591
+
592
+
593
+ def check_voice_sample(sample: "Optional[bytes]") -> "Optional[bytes]":
594
+ """Bound attacker-supplied media before it is written anywhere or handed to a
595
+ C/C++/torch decoder. The gateway also scans it; this is the node's own check,
596
+ because a node must not depend on its gateway having done the right thing."""
597
+ if not sample:
598
+ return None
599
+ cap = int(CONFIG.node_max_voice_sample_bytes)
600
+ if len(sample) > cap:
601
+ raise EngineGuardError(f"voice sample exceeds this node's {cap} byte limit")
602
+ return sample
603
+
604
+
605
+ # --------------------------------------------------------------------------
606
+ # 6. Least-privilege process posture
607
+ # --------------------------------------------------------------------------
608
+ # Variables a child genuinely needs to run. EVERYTHING else — every cloud token,
609
+ # every API key the owner exported into their shell — is dropped.
610
+ _ENV_KEEP = (
611
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
612
+ "TMPDIR", "TEMP", "TMP", "PWD",
613
+ # Windows needs these to start a process at all.
614
+ "SystemRoot", "SystemDrive", "windir", "COMSPEC", "PATHEXT", "USERPROFILE",
615
+ "APPDATA", "LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
616
+ # TLS trust + native libs + GPU visibility.
617
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "LD_LIBRARY_PATH",
618
+ "DYLD_LIBRARY_PATH", "CUDA_VISIBLE_DEVICES", "NVIDIA_VISIBLE_DEVICES",
619
+ # Python needs to find itself and the package.
620
+ "PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", "PYTHONIOENCODING",
621
+ )
622
+ # Our own config travels; nothing else with a prefix does.
623
+ _ENV_KEEP_PREFIXES = ("STEVAI_", "OLLAMA_")
624
+
625
+
626
+ def minimal_env(extra: "Optional[Dict[str, str]]" = None,
627
+ *, keep: Iterable[str] = _ENV_KEEP,
628
+ keep_prefixes: Iterable[str] = _ENV_KEEP_PREFIXES) -> Dict[str, str]:
629
+ """A child's environment, built by allowlist instead of inherited wholesale.
630
+
631
+ ``{**os.environ}`` on a developer's or a company server's machine hands the child
632
+ every credential on the box. This does not.
633
+ """
634
+ env: Dict[str, str] = {}
635
+ prefixes = tuple(keep_prefixes)
636
+ for k, v in os.environ.items():
637
+ if k in keep or (prefixes and k.startswith(prefixes)):
638
+ env[k] = v
639
+ for k, v in (extra or {}).items():
640
+ if v is None:
641
+ env.pop(k, None)
642
+ else:
643
+ env[k] = str(v)
644
+ return env
645
+
646
+
647
+ def node_home() -> Path:
648
+ """This node's private directory.
649
+
650
+ ``$STEVAI_WORKSPACE`` wins when the supervisor provided one — the supervisor has
651
+ been setting it and, until now, nothing read it, so its "wipe on stop" only ever
652
+ deleted a marker file. Anything we put here is genuinely wiped when sharing stops.
653
+ """
654
+ ws = (envvar("WORKSPACE") or "").strip()
655
+ root = Path(ws) if ws else config_dir() / "node"
656
+ return root
657
+
658
+
659
+ def _private_dir(path: Path) -> Path:
660
+ path.mkdir(parents=True, exist_ok=True)
661
+ try:
662
+ os.chmod(path, stat.S_IRWXU) # 0700 — owner only (no-op on Windows)
663
+ except OSError:
664
+ pass
665
+ return path
666
+
667
+
668
+ def models_dir() -> Path:
669
+ """Where the node keeps ITS models. Separate from ``~/.ollama`` on purpose: network
670
+ jobs must not read, evict or grow the owner's personal model store."""
671
+ override = (envvar("NODE_MODELS_DIR") or "").strip()
672
+ return _private_dir(Path(override) if override else node_home() / "models")
673
+
674
+
675
+ def scratch_dir() -> Path:
676
+ """Node-private scratch for untrusted bytes (e.g. a voice sample), instead of the
677
+ world-readable global temp directory."""
678
+ return _private_dir(node_home() / "scratch")
679
+
680
+
681
+ def engine_env(*, host: str = "127.0.0.1:11434",
682
+ models: "Optional[str]" = None,
683
+ extra: "Optional[Dict[str, str]]" = None) -> Dict[str, str]:
684
+ """Environment for an engine daemon WE start: loopback bind, private model store,
685
+ no inherited secrets. (Installs where a shell script or Electron starts the engine
686
+ do not go through here — see the module docstring.)"""
687
+ env = {
688
+ "OLLAMA_HOST": host,
689
+ "OLLAMA_MODELS": models or str(models_dir()),
690
+ "OLLAMA_ORIGINS": "http://127.0.0.1", # no drive-by from a web page
691
+ "OLLAMA_NOHISTORY": "1",
692
+ }
693
+ env.update(extra or {})
694
+ return minimal_env(env)
695
+
696
+
697
+ # --------------------------------------------------------------------------
698
+ # 7. Transport — the only place job-handling code talks to an engine
699
+ # --------------------------------------------------------------------------
700
+ async def post_json(base: str, path: str, payload: Dict, timeout: float) -> Dict:
701
+ """One guarded, non-streaming request. Raises on any transport/engine failure so
702
+ the caller re-routes rather than inventing an answer."""
703
+ url = engine_url(base, path)
704
+ async with httpx.AsyncClient(timeout=timeout) as client:
705
+ r = await client.post(url, json=payload)
706
+ r.raise_for_status()
707
+ return r.json()
708
+
709
+
710
+ @asynccontextmanager
711
+ async def stream(base: str, path: str, payload: Dict, timeout: float):
712
+ """One guarded streaming request. Yields the response object; leaving the block
713
+ closes the stream, which is what actually stops the engine generating."""
714
+ url = engine_url(base, path)
715
+ async with httpx.AsyncClient(timeout=timeout) as client:
716
+ async with client.stream("POST", url, json=payload) as r:
717
+ r.raise_for_status()
718
+ yield r