Skip to content

Commit 77ce8d1

Browse files
jmc-wanderclaude
andcommitted
Fix OllamaClient lifecycle, wire model validation pipeline, add HTTP security
- apprentice_class.py: __aenter__/__aexit__ now enter/exit OllamaClient async context so local model inference actually works - factory.py: Construct and wire ModelValidator into Apprentice instance - serve.py: Pipeline calls validate_and_promote() after fine-tuning; Add configurable security layer with 4 auth modes (none, api-key, jwt, hmac), TLS support, and IP allowlist; Default bind to 127.0.0.1 - cli.py/cli_models.py: Add --auth, --api-key, --jwt-secret, --hmac-secret, --tls-cert, --tls-key, --allowed-ips flags All 2,064 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 66c40e2 commit 77ce8d1

5 files changed

Lines changed: 328 additions & 21 deletions

File tree

src/apprentice/apprentice_class.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@ async def __aenter__(self):
392392
if hasattr(self._audit_log, 'open'):
393393
await self._audit_log.open()
394394

395+
# Enter OllamaClient async context (creates httpx.AsyncClient)
396+
if hasattr(self._local_client, '__aenter__'):
397+
await self._local_client.__aenter__()
398+
395399
# Record start time
396400
self._start_time_utc = datetime.now(timezone.utc)
397401

@@ -402,6 +406,13 @@ async def __aenter__(self):
402406

403407
async def __aexit__(self, exc_type, exc_val, exc_tb):
404408
"""Async context manager exit. Flushes audit log and releases resources."""
409+
try:
410+
# Close OllamaClient async context
411+
if hasattr(self._local_client, '__aexit__'):
412+
await self._local_client.__aexit__(None, None, None)
413+
except Exception:
414+
pass
415+
405416
try:
406417
# Flush and close audit log
407418
if hasattr(self._audit_log, 'flush'):

src/apprentice/cli/cli.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,22 @@ def parse_args(argv: list[str]) -> ParsedArgs:
158158

159159
# serve subcommand
160160
serve_parser = subparsers.add_parser("serve", help="Start HTTP daemon")
161-
serve_parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
161+
serve_parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
162162
serve_parser.add_argument("--port", type=int, default=8710, help="Bind port (default: 8710)")
163163
serve_parser.add_argument("--pipeline-interval", type=int, default=300, help="Pipeline interval seconds (default: 300)")
164+
serve_parser.add_argument("--auth", dest="auth_mode", default="none",
165+
choices=["none", "api-key", "jwt", "hmac"],
166+
help="Auth mode (default: none)")
167+
serve_parser.add_argument("--api-key", dest="serve_api_key", default="",
168+
help="API key or env:VAR_NAME for api-key auth")
169+
serve_parser.add_argument("--jwt-secret", default="",
170+
help="JWT secret or env:VAR_NAME for jwt auth")
171+
serve_parser.add_argument("--hmac-secret", default="",
172+
help="HMAC secret or env:VAR_NAME for hmac auth")
173+
serve_parser.add_argument("--tls-cert", default="", help="Path to TLS certificate")
174+
serve_parser.add_argument("--tls-key", default="", help="Path to TLS private key")
175+
serve_parser.add_argument("--allowed-ips", default="",
176+
help="Comma-separated IP allowlist (CIDRs or addresses)")
164177

165178
# Parse
166179
args = parser.parse_args(argv)
@@ -200,6 +213,13 @@ def parse_args(argv: list[str]) -> ParsedArgs:
200213
host=args.host,
201214
port=args.port,
202215
pipeline_interval=args.pipeline_interval,
216+
auth_mode=args.auth_mode,
217+
api_key=args.serve_api_key,
218+
jwt_secret=args.jwt_secret,
219+
hmac_secret=args.hmac_secret,
220+
tls_cert=args.tls_cert,
221+
tls_key=args.tls_key,
222+
allowed_ips=args.allowed_ips,
203223
)
204224

205225
return ParsedArgs(
@@ -532,10 +552,21 @@ def main(argv: Optional[list[str]] = None) -> int:
532552
except ImportError:
533553
from serve import serve_main
534554
serve = args.serve_args
535-
host = serve.host if serve else "0.0.0.0"
555+
host = serve.host if serve else "127.0.0.1"
536556
port = serve.port if serve else 8710
537557
interval = serve.pipeline_interval if serve else 300
538-
asyncio.run(serve_main(args.global_flags.config_path, host, port, interval))
558+
allowed_ips = [ip.strip() for ip in serve.allowed_ips.split(",") if ip.strip()] if serve and serve.allowed_ips else []
559+
asyncio.run(serve_main(
560+
config_path=args.global_flags.config_path,
561+
host=host, port=port, pipeline_interval=interval,
562+
auth_mode=serve.auth_mode if serve else "none",
563+
api_key=serve.api_key if serve else "",
564+
jwt_secret=serve.jwt_secret if serve else "",
565+
hmac_secret=serve.hmac_secret if serve else "",
566+
tls_cert=serve.tls_cert if serve else "",
567+
tls_key=serve.tls_key if serve else "",
568+
allowed_ips=allowed_ips,
569+
))
539570
return 0
540571

541572
# Load config

src/apprentice/cli/cli_models.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,16 @@ class InitArgs(BaseModel):
7979

8080
class ServeArgs(BaseModel):
8181
"""Parsed arguments specific to the 'serve' subcommand."""
82-
host: str = "0.0.0.0"
82+
host: str = "127.0.0.1"
8383
port: int = Field(default=8710, ge=1, le=65535)
8484
pipeline_interval: int = Field(default=300, ge=10)
85+
auth_mode: str = "none"
86+
api_key: str = ""
87+
jwt_secret: str = ""
88+
hmac_secret: str = ""
89+
tls_cert: str = ""
90+
tls_key: str = ""
91+
allowed_ips: str = ""
8592

8693
model_config = ConfigDict(strict=True)
8794

0 commit comments

Comments
 (0)