hull-core 0.1.0b1__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.
- hull_core/__init__.py +36 -0
- hull_core/auth/__init__.py +33 -0
- hull_core/auth/asgi.py +75 -0
- hull_core/auth/context.py +47 -0
- hull_core/auth/middleware.py +105 -0
- hull_core/auth/tokens.py +83 -0
- hull_core/auth/users.py +131 -0
- hull_core/cli.py +107 -0
- hull_core/config/__init__.py +39 -0
- hull_core/config/models.py +59 -0
- hull_core/config/settings.py +132 -0
- hull_core/crypto/__init__.py +5 -0
- hull_core/crypto/timing.py +16 -0
- hull_core/http/__init__.py +23 -0
- hull_core/http/ssrf.py +297 -0
- hull_core/lifecycle/__init__.py +5 -0
- hull_core/lifecycle/lock.py +278 -0
- hull_core/limits/__init__.py +5 -0
- hull_core/limits/limiter.py +34 -0
- hull_core/providers/__init__.py +5 -0
- hull_core/providers/openai_spec.py +147 -0
- hull_core/server/__init__.py +6 -0
- hull_core/server/app.py +78 -0
- hull_core/server/run.py +57 -0
- hull_core/storage/__init__.py +5 -0
- hull_core/storage/sqlite.py +69 -0
- hull_core-0.1.0b1.dist-info/METADATA +135 -0
- hull_core-0.1.0b1.dist-info/RECORD +75 -0
- hull_core-0.1.0b1.dist-info/WHEEL +4 -0
- hull_core-0.1.0b1.dist-info/entry_points.txt +3 -0
- hull_core-0.1.0b1.dist-info/licenses/LICENSE +201 -0
- hull_embedding_daemon/__init__.py +6 -0
- hull_embedding_daemon/__main__.py +53 -0
- hull_embedding_daemon/_extra.py +45 -0
- hull_embedding_daemon/api.py +82 -0
- hull_embedding_daemon/backends/__init__.py +0 -0
- hull_embedding_daemon/backends/gguf.py +17 -0
- hull_embedding_daemon/backends/onnx.py +17 -0
- hull_web/__init__.py +39 -0
- hull_web/_extra.py +51 -0
- hull_web/adapters/__init__.py +10 -0
- hull_web/adapters/mangadex.py +403 -0
- hull_web/browsers/__init__.py +19 -0
- hull_web/browsers/browserless.py +66 -0
- hull_web/browsers/cf_render.py +83 -0
- hull_web/browsers/interact.py +132 -0
- hull_web/browsers/invisible.py +76 -0
- hull_web/browsers/patchright.py +73 -0
- hull_web/browsers/protocol.py +27 -0
- hull_web/fingerprint.py +209 -0
- hull_web/http/__init__.py +13 -0
- hull_web/http/client.py +233 -0
- hull_web/http/url.py +137 -0
- hull_web/py.typed +0 -0
- hull_web/scraper/__init__.py +19 -0
- hull_web/scraper/agent.py +391 -0
- hull_web/scraper/base.py +31 -0
- hull_web/scraper/cache.py +117 -0
- hull_web/scraper/robots.py +110 -0
- hull_web/scraper/selector_inference.py +212 -0
- hull_web/scraper/state.py +37 -0
- hull_web/scraper/strategies/__init__.py +22 -0
- hull_web/scraper/strategies/api_direct.py +80 -0
- hull_web/scraper/strategies/basic_http.py +101 -0
- hull_web/scraper/strategies/captcha.py +295 -0
- hull_web/scraper/strategies/headless.py +106 -0
- hull_web/scraper/strategies/invisible_browser.py +32 -0
- hull_web/scraper/strategies/patchright_browser.py +194 -0
- hull_web/scraper/strategies/remote_render.py +49 -0
- hull_web/scraper/strategies/tls_spoof.py +97 -0
- hull_web/scraper/utils.py +190 -0
- hull_web/search/__init__.py +7 -0
- hull_web/search/client.py +294 -0
- hull_web/search/models.py +33 -0
- hull_web/search/runner.py +1448 -0
hull_core/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""hull-core: shared auth, model config, limiter, storage, and MCP server core."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("hull-core")
|
|
7
|
+
except PackageNotFoundError: # package not installed (e.g. running from source tree)
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
from hull_core.auth.context import AuthContext, current_user
|
|
11
|
+
from hull_core.auth.middleware import Authenticator
|
|
12
|
+
from hull_core.auth.tokens import hash_token, verify_token
|
|
13
|
+
from hull_core.auth.users import User, UserLimits, UsersError, load_users
|
|
14
|
+
from hull_core.config.models import ModelCell, resolve_model_cells
|
|
15
|
+
from hull_core.config.settings import HullSettings, ServerSettings, load_settings
|
|
16
|
+
from hull_core.limits.limiter import SlidingWindowLimiter
|
|
17
|
+
from hull_core.storage.sqlite import HullDatabase
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AuthContext",
|
|
21
|
+
"Authenticator",
|
|
22
|
+
"HullDatabase",
|
|
23
|
+
"HullSettings",
|
|
24
|
+
"ModelCell",
|
|
25
|
+
"ServerSettings",
|
|
26
|
+
"SlidingWindowLimiter",
|
|
27
|
+
"User",
|
|
28
|
+
"UserLimits",
|
|
29
|
+
"UsersError",
|
|
30
|
+
"current_user",
|
|
31
|
+
"hash_token",
|
|
32
|
+
"load_settings",
|
|
33
|
+
"load_users",
|
|
34
|
+
"resolve_model_cells",
|
|
35
|
+
"verify_token",
|
|
36
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Token authentication: users.toml, three modes, ASGI middleware, identity context."""
|
|
2
|
+
|
|
3
|
+
from hull_core.auth.asgi import HullAuthMiddleware
|
|
4
|
+
from hull_core.auth.context import AuthContext, current_user, reset_current_user, set_current_user
|
|
5
|
+
from hull_core.auth.middleware import Authenticator, AuthOutcome
|
|
6
|
+
from hull_core.auth.tokens import TokenHashError, hash_token, verify_token
|
|
7
|
+
from hull_core.auth.users import (
|
|
8
|
+
User,
|
|
9
|
+
UserLimits,
|
|
10
|
+
UsersError,
|
|
11
|
+
ensure_path_allowed,
|
|
12
|
+
find_user_by_token,
|
|
13
|
+
load_users,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AuthContext",
|
|
18
|
+
"AuthOutcome",
|
|
19
|
+
"Authenticator",
|
|
20
|
+
"HullAuthMiddleware",
|
|
21
|
+
"TokenHashError",
|
|
22
|
+
"User",
|
|
23
|
+
"UserLimits",
|
|
24
|
+
"UsersError",
|
|
25
|
+
"current_user",
|
|
26
|
+
"ensure_path_allowed",
|
|
27
|
+
"find_user_by_token",
|
|
28
|
+
"hash_token",
|
|
29
|
+
"load_users",
|
|
30
|
+
"reset_current_user",
|
|
31
|
+
"set_current_user",
|
|
32
|
+
"verify_token",
|
|
33
|
+
]
|
hull_core/auth/asgi.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Pure-ASGI middleware that authenticates the request and binds the identity.
|
|
2
|
+
|
|
3
|
+
Runs before the MCP endpoint (passed through FastMCP's ``http_app`` middleware
|
|
4
|
+
list) and publishes :class:`AuthContext` via the module contextvar so tools
|
|
5
|
+
can scope storage access per user. Rejections are JSON:
|
|
6
|
+
``401`` (missing/invalid token), ``403`` (disabled user), ``429`` (over limit).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from hull_core.auth.context import set_current_user, reset_current_user
|
|
14
|
+
from hull_core.auth.middleware import Authenticator
|
|
15
|
+
|
|
16
|
+
_STATUS_TEXT = {
|
|
17
|
+
401: "unauthorized",
|
|
18
|
+
403: "forbidden",
|
|
19
|
+
429: "rate limit exceeded",
|
|
20
|
+
500: "server misconfiguration",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HullAuthMiddleware:
|
|
25
|
+
"""ASGI middleware: authenticate → bind contextvar → forward, or reject."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, app, authenticator: Authenticator) -> None:
|
|
28
|
+
self.app = app
|
|
29
|
+
self.authenticator = authenticator
|
|
30
|
+
|
|
31
|
+
async def __call__(self, scope, receive, send) -> None: # noqa: ANN001
|
|
32
|
+
if scope["type"] != "http":
|
|
33
|
+
await self.app(scope, receive, send)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
authz = None
|
|
37
|
+
for name, value in scope.get("headers", []):
|
|
38
|
+
if name == b"authorization":
|
|
39
|
+
authz = value.decode("latin-1")
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
outcome = self.authenticator.authenticate(authz)
|
|
43
|
+
if not outcome.ok:
|
|
44
|
+
await self._reject(send, outcome.status or 500, outcome.detail)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
context = outcome.context
|
|
48
|
+
if context is None:
|
|
49
|
+
# ok=True always carries a context; fail closed rather than bind a
|
|
50
|
+
# null identity into the contextvar for the rest of the request.
|
|
51
|
+
await self._reject(send, 500, "authenticator returned no context")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
token = set_current_user(context)
|
|
55
|
+
try:
|
|
56
|
+
await self.app(scope, receive, send)
|
|
57
|
+
finally:
|
|
58
|
+
reset_current_user(token)
|
|
59
|
+
|
|
60
|
+
async def _reject(self, send, status: int, detail: str) -> None: # noqa: ANN001
|
|
61
|
+
body = json.dumps({"error": _STATUS_TEXT.get(status, "error"), "detail": detail}).encode("utf-8")
|
|
62
|
+
headers = [
|
|
63
|
+
(b"content-type", b"application/json"),
|
|
64
|
+
(b"content-length", str(len(body)).encode()),
|
|
65
|
+
]
|
|
66
|
+
if status == 401:
|
|
67
|
+
headers.append((b"www-authenticate", b"Bearer"))
|
|
68
|
+
await send(
|
|
69
|
+
{
|
|
70
|
+
"type": "http.response.start",
|
|
71
|
+
"status": status,
|
|
72
|
+
"headers": headers,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Request-scoped identity for MCP tools.
|
|
2
|
+
|
|
3
|
+
The pure-ASGI auth middleware resolves the caller before the MCP endpoint
|
|
4
|
+
runs and stores the resulting :class:`AuthContext` in a contextvar. Tools read
|
|
5
|
+
it with :func:`current_user` and scope every storage access by its namespace.
|
|
6
|
+
A pure-ASGI middleware (not Starlette's BaseHTTPMiddleware) is used so the
|
|
7
|
+
contextvar propagates into the endpoint task without anyio task-group copies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from contextvars import ContextVar, Token
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
_CURRENT: ContextVar["AuthContext | None"] = ContextVar("hull_current_user", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AuthContext:
|
|
20
|
+
"""Resolved caller identity attached to one HTTP request."""
|
|
21
|
+
|
|
22
|
+
uid: str
|
|
23
|
+
namespace: str
|
|
24
|
+
mode: str
|
|
25
|
+
allowed_roots: tuple[str, ...] = ()
|
|
26
|
+
rpm: int | None = None
|
|
27
|
+
disabled: bool = False
|
|
28
|
+
attributes: dict = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def local() -> "AuthContext":
|
|
32
|
+
"""Identity used in no-auth mode (and for in-process tool calls)."""
|
|
33
|
+
return AuthContext(uid="local", namespace="default", mode="no-auth")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_current_user(ctx: AuthContext) -> Token:
|
|
37
|
+
return _CURRENT.set(ctx)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reset_current_user(token: Token) -> None:
|
|
41
|
+
_CURRENT.reset(token)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def current_user() -> AuthContext:
|
|
45
|
+
"""The caller's identity; falls back to the local identity when unset."""
|
|
46
|
+
ctx = _CURRENT.get()
|
|
47
|
+
return ctx if ctx is not None else AuthContext.local()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Token authentication: one mechanism, three modes (spec §4).
|
|
2
|
+
|
|
3
|
+
Mode is a config state, resolved by the host in ``config.toml``:
|
|
4
|
+
|
|
5
|
+
- ``no-auth`` — localhost-only; one shared namespace; no Authorization header.
|
|
6
|
+
- ``token`` — one shared token; still one shared namespace.
|
|
7
|
+
- ``multi`` — users.toml; token → user → namespace; 401 wrong token,
|
|
8
|
+
403 disabled user, 429 over limit.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from hull_core.auth.context import AuthContext
|
|
16
|
+
from hull_core.auth.tokens import verify_token
|
|
17
|
+
from hull_core.auth.users import User, UsersError, find_user_by_token
|
|
18
|
+
from hull_core.config.settings import HullSettings
|
|
19
|
+
from hull_core.limits.limiter import SlidingWindowLimiter
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AuthOutcome:
|
|
24
|
+
"""Result of authenticating one request."""
|
|
25
|
+
|
|
26
|
+
context: AuthContext | None = None
|
|
27
|
+
status: int | None = None # None = proceed; 401/403 = reject
|
|
28
|
+
detail: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def ok(self) -> bool:
|
|
32
|
+
return self.status is None and self.context is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Authenticator:
|
|
36
|
+
"""Resolves bearer tokens to AuthContexts according to the configured mode."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
settings: HullSettings,
|
|
41
|
+
users: dict[str, User] | None = None,
|
|
42
|
+
limiter: SlidingWindowLimiter | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._settings = settings
|
|
45
|
+
self._mode = settings.server.auth
|
|
46
|
+
self._users = users
|
|
47
|
+
self._limiter = limiter if limiter is not None else SlidingWindowLimiter()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def mode(self) -> str:
|
|
51
|
+
return self._mode
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def limiter(self) -> SlidingWindowLimiter:
|
|
55
|
+
return self._limiter
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _bearer(authz: str | None) -> str | None:
|
|
59
|
+
if not authz:
|
|
60
|
+
return None
|
|
61
|
+
scheme, _, value = authz.partition(" ")
|
|
62
|
+
if scheme.lower() != "bearer" or not value.strip():
|
|
63
|
+
return None
|
|
64
|
+
return value.strip()
|
|
65
|
+
|
|
66
|
+
def authenticate(self, authz: str | None) -> AuthOutcome:
|
|
67
|
+
if self._mode == "no-auth":
|
|
68
|
+
return AuthOutcome(context=AuthContext.local())
|
|
69
|
+
|
|
70
|
+
token = self._bearer(authz)
|
|
71
|
+
if token is None:
|
|
72
|
+
return AuthOutcome(status=401, detail="missing or malformed Authorization header")
|
|
73
|
+
|
|
74
|
+
if self._mode == "token":
|
|
75
|
+
token_hash = self._settings.server.token_hash
|
|
76
|
+
if token_hash is None or not verify_token(token, token_hash):
|
|
77
|
+
return AuthOutcome(status=401, detail="invalid token")
|
|
78
|
+
return self._admit(
|
|
79
|
+
AuthContext(uid="shared", namespace="default", mode="token", rpm=self._settings.server.rpm)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if self._mode == "multi":
|
|
83
|
+
if self._users is None:
|
|
84
|
+
raise UsersError("multi mode requires the users table (load users.toml first)")
|
|
85
|
+
user = find_user_by_token(self._users, token)
|
|
86
|
+
if user is None:
|
|
87
|
+
return AuthOutcome(status=401, detail="invalid token")
|
|
88
|
+
if not user.enabled:
|
|
89
|
+
return AuthOutcome(status=403, detail=f"user {user.uid!r} is disabled")
|
|
90
|
+
return self._admit(
|
|
91
|
+
AuthContext(
|
|
92
|
+
uid=user.uid,
|
|
93
|
+
namespace=user.namespace,
|
|
94
|
+
mode="multi",
|
|
95
|
+
allowed_roots=user.allowed_roots,
|
|
96
|
+
rpm=user.limits.rpm,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return AuthOutcome(status=500, detail=f"unknown auth mode {self._mode!r}")
|
|
101
|
+
|
|
102
|
+
def _admit(self, ctx: AuthContext) -> AuthOutcome:
|
|
103
|
+
if not self._limiter.check(ctx.uid, ctx.rpm):
|
|
104
|
+
return AuthOutcome(status=429, detail="rate limit exceeded")
|
|
105
|
+
return AuthOutcome(context=ctx)
|
hull_core/auth/tokens.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Token hashing and verification (host-side credential material).
|
|
2
|
+
|
|
3
|
+
Tokens never travel to disk in plaintext: the host stores a scrypt hash in
|
|
4
|
+
``users.toml`` / ``config.toml`` and verifies presented bearer tokens against
|
|
5
|
+
it. Format::
|
|
6
|
+
|
|
7
|
+
scrypt$<n>$<r>$<p>$<salt-hex>$<derived-key-hex>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import secrets
|
|
15
|
+
|
|
16
|
+
_SCRYPT_N = 2**15
|
|
17
|
+
_SCRYPT_R = 8
|
|
18
|
+
_SCRYPT_P = 1
|
|
19
|
+
_DKLEN = 32
|
|
20
|
+
_PREFIX = "scrypt"
|
|
21
|
+
# 128 * N * r = 32 MiB working memory; OpenSSL's default cap is lower on some
|
|
22
|
+
# builds, so grant an explicit 64 MiB ceiling.
|
|
23
|
+
_MAXMEM = 64 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TokenHashError(ValueError):
|
|
27
|
+
"""Raised when an encoded token hash is malformed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hash_token(token: str) -> str:
|
|
31
|
+
"""Hash a bearer token into the storable ``scrypt$...`` encoding."""
|
|
32
|
+
salt = secrets.token_bytes(16)
|
|
33
|
+
dk = hashlib.scrypt(
|
|
34
|
+
token.encode("utf-8"),
|
|
35
|
+
salt=salt,
|
|
36
|
+
n=_SCRYPT_N,
|
|
37
|
+
r=_SCRYPT_R,
|
|
38
|
+
p=_SCRYPT_P,
|
|
39
|
+
dklen=_DKLEN,
|
|
40
|
+
maxmem=_MAXMEM,
|
|
41
|
+
)
|
|
42
|
+
return "$".join(
|
|
43
|
+
[
|
|
44
|
+
_PREFIX,
|
|
45
|
+
str(_SCRYPT_N),
|
|
46
|
+
str(_SCRYPT_R),
|
|
47
|
+
str(_SCRYPT_P),
|
|
48
|
+
salt.hex(),
|
|
49
|
+
dk.hex(),
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def verify_token(token: str, encoded: str) -> bool:
|
|
55
|
+
"""Constant-time verify a presented token against an encoded hash."""
|
|
56
|
+
try:
|
|
57
|
+
prefix, n_s, r_s, p_s, salt_hex, dk_hex = encoded.split("$")
|
|
58
|
+
n, r, p = int(n_s), int(r_s), int(p_s)
|
|
59
|
+
salt = bytes.fromhex(salt_hex)
|
|
60
|
+
expected = bytes.fromhex(dk_hex)
|
|
61
|
+
except (ValueError, AttributeError) as exc:
|
|
62
|
+
raise TokenHashError(f"malformed token hash encoding: {encoded[:24]!r}") from exc
|
|
63
|
+
if prefix != _PREFIX or not salt or not expected:
|
|
64
|
+
raise TokenHashError(f"malformed token hash encoding: {encoded[:24]!r}")
|
|
65
|
+
actual = hashlib.scrypt(
|
|
66
|
+
token.encode("utf-8"),
|
|
67
|
+
salt=salt,
|
|
68
|
+
n=n,
|
|
69
|
+
r=r,
|
|
70
|
+
p=p,
|
|
71
|
+
dklen=len(expected),
|
|
72
|
+
maxmem=_MAXMEM,
|
|
73
|
+
)
|
|
74
|
+
return hmac.compare_digest(actual, expected)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_valid_encoding(encoded: str) -> bool:
|
|
78
|
+
"""True when ``encoded`` parses as a scrypt token hash."""
|
|
79
|
+
try:
|
|
80
|
+
verify_token("", encoded)
|
|
81
|
+
except TokenHashError:
|
|
82
|
+
return False
|
|
83
|
+
return True
|
hull_core/auth/users.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""users.toml — the multi-user table (auth mode 3).
|
|
2
|
+
|
|
3
|
+
Schema (spec §4)::
|
|
4
|
+
|
|
5
|
+
[users.<uid>]
|
|
6
|
+
token_hash = "scrypt$..." # mint with `hull token hash`
|
|
7
|
+
enabled = true
|
|
8
|
+
namespace = "<uid-or-shared>" # data isolation root
|
|
9
|
+
allowed_roots = ["/data/alice"] # filesystem roots tools may touch
|
|
10
|
+
[users.<uid>.limits]
|
|
11
|
+
rpm = 60 # optional per-minute request cap
|
|
12
|
+
|
|
13
|
+
Admin is host-side only: edit the file, restart the server. There is no admin
|
|
14
|
+
API and no self-service.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import tomllib
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Protocol
|
|
23
|
+
|
|
24
|
+
from hull_core.auth.tokens import is_valid_encoding, verify_token
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsersError(ValueError):
|
|
28
|
+
"""Raised when users.toml is missing, malformed, or semantically invalid."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class UserLimits:
|
|
33
|
+
rpm: int | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class User:
|
|
38
|
+
uid: str
|
|
39
|
+
token_hash: str
|
|
40
|
+
enabled: bool
|
|
41
|
+
namespace: str
|
|
42
|
+
allowed_roots: tuple[str, ...] = ()
|
|
43
|
+
limits: UserLimits = field(default_factory=UserLimits)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_users(path: Path) -> dict[str, User]:
|
|
47
|
+
"""Parse and validate users.toml into a ``uid → User`` mapping."""
|
|
48
|
+
if not path.is_file():
|
|
49
|
+
raise UsersError(f"users file not found: {path}")
|
|
50
|
+
try:
|
|
51
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
except tomllib.TOMLDecodeError as exc:
|
|
53
|
+
raise UsersError(f"users file is not valid TOML: {path}: {exc}") from exc
|
|
54
|
+
|
|
55
|
+
table = raw.get("users")
|
|
56
|
+
if not isinstance(table, dict) or not table:
|
|
57
|
+
raise UsersError("users file must contain a non-empty [users] table")
|
|
58
|
+
|
|
59
|
+
users: dict[str, User] = {}
|
|
60
|
+
for uid, entry in table.items():
|
|
61
|
+
if not isinstance(entry, dict):
|
|
62
|
+
raise UsersError(f"user {uid!r}: expected a table")
|
|
63
|
+
token_hash = entry.get("token_hash")
|
|
64
|
+
if not isinstance(token_hash, str) or not is_valid_encoding(token_hash):
|
|
65
|
+
raise UsersError(
|
|
66
|
+
f"user {uid!r}: token_hash missing or not a scrypt encoding (mint one with `hull token hash`)"
|
|
67
|
+
)
|
|
68
|
+
namespace = entry.get("namespace")
|
|
69
|
+
if not isinstance(namespace, str) or not namespace:
|
|
70
|
+
raise UsersError(f"user {uid!r}: namespace must be a non-empty string")
|
|
71
|
+
enabled = entry.get("enabled", True)
|
|
72
|
+
if not isinstance(enabled, bool):
|
|
73
|
+
raise UsersError(f"user {uid!r}: enabled must be a boolean")
|
|
74
|
+
roots = entry.get("allowed_roots", [])
|
|
75
|
+
if not isinstance(roots, list) or not all(isinstance(r, str) for r in roots):
|
|
76
|
+
raise UsersError(f"user {uid!r}: allowed_roots must be a list of strings")
|
|
77
|
+
limits_raw = entry.get("limits", {})
|
|
78
|
+
if not isinstance(limits_raw, dict):
|
|
79
|
+
raise UsersError(f"user {uid!r}: limits must be a table")
|
|
80
|
+
rpm = limits_raw.get("rpm")
|
|
81
|
+
if rpm is not None and (not isinstance(rpm, int) or rpm < 1):
|
|
82
|
+
raise UsersError(f"user {uid!r}: limits.rpm must be a positive integer")
|
|
83
|
+
users[uid] = User(
|
|
84
|
+
uid=uid,
|
|
85
|
+
token_hash=token_hash,
|
|
86
|
+
enabled=enabled,
|
|
87
|
+
namespace=namespace,
|
|
88
|
+
allowed_roots=tuple(roots),
|
|
89
|
+
limits=UserLimits(rpm=rpm),
|
|
90
|
+
)
|
|
91
|
+
return users
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_user_by_token(users: dict[str, User], token: str) -> User | None:
|
|
95
|
+
"""Return the user whose token_hash verifies ``token``, else None."""
|
|
96
|
+
for user in users.values():
|
|
97
|
+
if verify_token(token, user.token_hash):
|
|
98
|
+
return user
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PathScope(Protocol):
|
|
103
|
+
"""Anything carrying a caller's filesystem roots.
|
|
104
|
+
|
|
105
|
+
Both the users.toml row (:class:`User`) and the per-request
|
|
106
|
+
:class:`~hull_core.auth.context.AuthContext` reach ``ensure_path_allowed``,
|
|
107
|
+
so the guard takes the shape both already have instead of one of them.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def uid(self) -> str:
|
|
112
|
+
"""Caller id used in the denial message."""
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def allowed_roots(self) -> tuple[str, ...]:
|
|
116
|
+
"""Roots the caller may touch; empty grants nothing."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def ensure_path_allowed(user: PathScope, path: Path) -> None:
|
|
120
|
+
"""Enforce the user's allowed_roots (spec §4 Q6).
|
|
121
|
+
|
|
122
|
+
An empty allowed_roots list grants nothing. Roots are matched lexically
|
|
123
|
+
after resolving both sides, so symlinked escapes require the resolved
|
|
124
|
+
target to sit under a root too.
|
|
125
|
+
"""
|
|
126
|
+
resolved = path.resolve()
|
|
127
|
+
for root in user.allowed_roots:
|
|
128
|
+
root_resolved = Path(root).resolve()
|
|
129
|
+
if resolved == root_resolved or root_resolved in resolved.parents:
|
|
130
|
+
return
|
|
131
|
+
raise PermissionError(f"path {resolved} is outside allowed roots for user {user.uid!r}")
|
hull_core/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Host-side CLI: control plane + consumer.
|
|
2
|
+
|
|
3
|
+
Subcommands: ``server start``, ``token hash``, ``config init|path|show``,
|
|
4
|
+
``db path``. With the server down, consumers are told to start it first —
|
|
5
|
+
there is no hidden local-core mode (spec §3).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from hull_core.auth.tokens import hash_token
|
|
14
|
+
from hull_core.config.settings import (
|
|
15
|
+
CONFIG_TEMPLATE,
|
|
16
|
+
default_config_dir,
|
|
17
|
+
load_settings,
|
|
18
|
+
write_default_config,
|
|
19
|
+
)
|
|
20
|
+
from hull_core.server.run import ServerConfigError, run_server
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cmd_server_start(args: argparse.Namespace) -> int:
|
|
24
|
+
try:
|
|
25
|
+
run_server(host=args.host, port=args.port)
|
|
26
|
+
except ServerConfigError as exc:
|
|
27
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
28
|
+
return 2
|
|
29
|
+
except KeyboardInterrupt:
|
|
30
|
+
pass
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cmd_token_hash(args: argparse.Namespace) -> int:
|
|
35
|
+
print(hash_token(args.token))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmd_config_init(args: argparse.Namespace) -> int:
|
|
40
|
+
try:
|
|
41
|
+
path = write_default_config(force=args.force)
|
|
42
|
+
except FileExistsError as exc:
|
|
43
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
print(f"wrote {path}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cmd_config_path(_args: argparse.Namespace) -> int:
|
|
50
|
+
print(default_config_dir() / "config.toml")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cmd_config_show(_args: argparse.Namespace) -> int:
|
|
55
|
+
path = default_config_dir() / "config.toml"
|
|
56
|
+
if path.is_file():
|
|
57
|
+
print(path.read_text(encoding="utf-8"), end="")
|
|
58
|
+
else:
|
|
59
|
+
print(CONFIG_TEMPLATE, end="")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cmd_db_path(_args: argparse.Namespace) -> int:
|
|
64
|
+
settings = load_settings()
|
|
65
|
+
print(settings.config_dir / "hull.db")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
70
|
+
parser = argparse.ArgumentParser(prog="hull", description="hull shared core control plane")
|
|
71
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
72
|
+
|
|
73
|
+
server = sub.add_parser("server", help="server lifecycle")
|
|
74
|
+
server_sub = server.add_subparsers(dest="server_command", required=True)
|
|
75
|
+
start = server_sub.add_parser("start", help="start the HTTP MCP server (blocking)")
|
|
76
|
+
start.add_argument("--host", default=None, help="bind host (default from config)")
|
|
77
|
+
start.add_argument("--port", type=int, default=None, help="bind port (default from config)")
|
|
78
|
+
start.set_defaults(func=_cmd_server_start)
|
|
79
|
+
|
|
80
|
+
token = sub.add_parser("token", help="token utilities")
|
|
81
|
+
token_sub = token.add_subparsers(dest="token_command", required=True)
|
|
82
|
+
token_hash_p = token_sub.add_parser("hash", help="mint a scrypt token_hash from a plaintext token")
|
|
83
|
+
token_hash_p.add_argument("token", help="plaintext token (keep it out of shells that log history)")
|
|
84
|
+
token_hash_p.set_defaults(func=_cmd_token_hash)
|
|
85
|
+
|
|
86
|
+
config = sub.add_parser("config", help="instance config utilities")
|
|
87
|
+
config_sub = config.add_subparsers(dest="config_command", required=True)
|
|
88
|
+
init_p = config_sub.add_parser("init", help="write ~/.hull/config.toml from the template")
|
|
89
|
+
init_p.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
90
|
+
init_p.set_defaults(func=_cmd_config_init)
|
|
91
|
+
path_p = config_sub.add_parser("path", help="print the config file path")
|
|
92
|
+
path_p.set_defaults(func=_cmd_config_path)
|
|
93
|
+
show_p = config_sub.add_parser("show", help="print the effective config (template if absent)")
|
|
94
|
+
show_p.set_defaults(func=_cmd_config_show)
|
|
95
|
+
|
|
96
|
+
db = sub.add_parser("db", help="storage utilities")
|
|
97
|
+
db_sub = db.add_subparsers(dest="db_command", required=True)
|
|
98
|
+
db_path_p = db_sub.add_parser("path", help="print the SQLite database path")
|
|
99
|
+
db_path_p.set_defaults(func=_cmd_db_path)
|
|
100
|
+
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: list[str] | None = None) -> int:
|
|
105
|
+
parser = build_parser()
|
|
106
|
+
args = parser.parse_args(argv)
|
|
107
|
+
return int(args.func(args))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Instance config: server mode + per-task model cells."""
|
|
2
|
+
|
|
3
|
+
from hull_core.config.models import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
DEFAULT_MODELS,
|
|
6
|
+
TASKS,
|
|
7
|
+
ModelCell,
|
|
8
|
+
api_key_env,
|
|
9
|
+
model_cell_from_config,
|
|
10
|
+
resolve_model_cells,
|
|
11
|
+
)
|
|
12
|
+
from hull_core.config.settings import (
|
|
13
|
+
AUTH_MODES,
|
|
14
|
+
CONFIG_TEMPLATE,
|
|
15
|
+
ConfigError,
|
|
16
|
+
HullSettings,
|
|
17
|
+
ServerSettings,
|
|
18
|
+
default_config_dir,
|
|
19
|
+
load_settings,
|
|
20
|
+
write_default_config,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AUTH_MODES",
|
|
25
|
+
"CONFIG_TEMPLATE",
|
|
26
|
+
"DEFAULT_BASE_URL",
|
|
27
|
+
"DEFAULT_MODELS",
|
|
28
|
+
"TASKS",
|
|
29
|
+
"ConfigError",
|
|
30
|
+
"HullSettings",
|
|
31
|
+
"ModelCell",
|
|
32
|
+
"ServerSettings",
|
|
33
|
+
"api_key_env",
|
|
34
|
+
"default_config_dir",
|
|
35
|
+
"load_settings",
|
|
36
|
+
"model_cell_from_config",
|
|
37
|
+
"resolve_model_cells",
|
|
38
|
+
"write_default_config",
|
|
39
|
+
]
|