engineering-argument-language 3.2.3__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.
- eal/__init__.py +3 -0
- eal/abstractions.py +144 -0
- eal/acquisition_coordination.py +80 -0
- eal/api_load_methods.py +77 -0
- eal/aspic.py +478 -0
- eal/aspic_compiler.py +445 -0
- eal/aspic_export.py +309 -0
- eal/builtin_methods.py +115 -0
- eal/catalogue.py +384 -0
- eal/cli.py +204 -0
- eal/client_transports.py +69 -0
- eal/collection_identity.py +28 -0
- eal/collection_scheduler.py +72 -0
- eal/command_process.py +118 -0
- eal/command_supervisor.py +112 -0
- eal/composition.py +531 -0
- eal/credentials.py +33 -0
- eal/dialectic.py +321 -0
- eal/discovery.py +127 -0
- eal/evaluator.py +619 -0
- eal/expressions.py +404 -0
- eal/extensions.py +38 -0
- eal/formatter.py +156 -0
- eal/generated/EALLexer.py +434 -0
- eal/generated/EALParser.py +6559 -0
- eal/generated/EALVisitor.py +393 -0
- eal/generated/__init__.py +1 -0
- eal/host.py +179 -0
- eal/host_redaction.py +51 -0
- eal/knowledge.py +96 -0
- eal/limits.py +88 -0
- eal/mcp_guard.py +56 -0
- eal/methods.py +463 -0
- eal/model.py +266 -0
- eal/model_context.py +55 -0
- eal/modes.py +121 -0
- eal/observation_reuse.py +128 -0
- eal/operation_contracts.py +25 -0
- eal/operations.py +155 -0
- eal/packets.py +604 -0
- eal/parser.py +407 -0
- eal/planning.py +136 -0
- eal/propositions.py +231 -0
- eal/reachability.py +85 -0
- eal/reasoning/__init__.py +25 -0
- eal/reasoning/abductive.py +45 -0
- eal/reasoning/analogical.py +41 -0
- eal/reasoning/causal.py +46 -0
- eal/reasoning/counterfactual.py +64 -0
- eal/reasoning/deductive.py +72 -0
- eal/reasoning/inductive.py +29 -0
- eal/reasoning/strategy.py +18 -0
- eal/reasoning/structured.py +11 -0
- eal/reasoning/temporal.py +47 -0
- eal/reasoning/validation.py +90 -0
- eal/registered_assessment.py +181 -0
- eal/runtime.py +454 -0
- eal/sampled_negative.py +131 -0
- eal/scope_transfer.py +59 -0
- eal/semantics.py +656 -0
- eal/server.py +79 -0
- eal/server_auth.py +37 -0
- eal/server_settings.py +258 -0
- eal/source_printer.py +129 -0
- eal/store.py +272 -0
- eal/tool_acquisition.py +383 -0
- engineering_argument_language-3.2.3.dist-info/METADATA +188 -0
- engineering_argument_language-3.2.3.dist-info/RECORD +72 -0
- engineering_argument_language-3.2.3.dist-info/WHEEL +5 -0
- engineering_argument_language-3.2.3.dist-info/entry_points.txt +4 -0
- engineering_argument_language-3.2.3.dist-info/licenses/LICENSE +24 -0
- engineering_argument_language-3.2.3.dist-info/top_level.txt +1 -0
eal/server.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Compose and launch the EAL FastMCP 4 adapter over stdio or HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from fastmcp.server.auth import AuthProvider
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .limits import ExecutionLimits
|
|
13
|
+
from .mcp_guard import OperationGuard, guard_acquisition
|
|
14
|
+
from .operations import create_operations
|
|
15
|
+
from .runtime import ReasoningService, load_method_registry
|
|
16
|
+
from .server_auth import configured_auth
|
|
17
|
+
from .server_settings import ServerSettings, parse_server_settings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_server(service: ReasoningService, *,
|
|
21
|
+
known_entries: Collection[str] | None = None,
|
|
22
|
+
exposure: str | None = None, max_in_flight: int = 1,
|
|
23
|
+
auth: AuthProvider | None = None) -> FastMCP:
|
|
24
|
+
"""Register one application catalogue with equivalent transport contracts."""
|
|
25
|
+
operations = create_operations(service, known_entries=known_entries, exposure=exposure)
|
|
26
|
+
server = FastMCP(
|
|
27
|
+
"EAL engineering reasoning", version=__version__, auth=auth,
|
|
28
|
+
instructions="Validate explicit engineering arguments, collect configured observations, reason over their declared scope, and explain results. Support is relative to declared inference rationales, not a proof of prose truth.",
|
|
29
|
+
strict_input_validation=True,
|
|
30
|
+
)
|
|
31
|
+
schemas: dict[str, dict[str, Any]] = {}
|
|
32
|
+
for name, handler in operations.items():
|
|
33
|
+
registered = server.add_tool(guard_acquisition(name, handler, service.workspace))
|
|
34
|
+
schemas[name] = registered.parameters
|
|
35
|
+
server.add_middleware(OperationGuard(schemas, max_in_flight=max_in_flight))
|
|
36
|
+
return server
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_server(settings: ServerSettings, *,
|
|
40
|
+
environ: Mapping[str, str] | None = None) -> FastMCP:
|
|
41
|
+
"""Resolve trusted dependencies once; reject missing auth before creating state."""
|
|
42
|
+
auth = configured_auth(settings, environ=environ)
|
|
43
|
+
service = ReasoningService(
|
|
44
|
+
settings.workspace, settings.registry, settings.database,
|
|
45
|
+
method_registry=load_method_registry(settings.methods),
|
|
46
|
+
limits=ExecutionLimits.load(settings.limits) if settings.limits else ExecutionLimits(),
|
|
47
|
+
)
|
|
48
|
+
return create_server(service, known_entries=settings.known_entries,
|
|
49
|
+
exposure=settings.exposure, max_in_flight=settings.max_in_flight,
|
|
50
|
+
auth=auth)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_stdio(server: FastMCP, settings: ServerSettings) -> None:
|
|
54
|
+
server.run(transport="stdio", show_banner=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_http(server: FastMCP, settings: ServerSettings) -> None:
|
|
58
|
+
server.run(transport="http", host=settings.host, port=settings.port,
|
|
59
|
+
path=settings.path, stateless_http=True, show_banner=False,
|
|
60
|
+
log_level="warning", host_origin_protection=True,
|
|
61
|
+
allowed_hosts=[settings.host, *settings.allowed_hosts],
|
|
62
|
+
allowed_origins=list(settings.allowed_origins))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_RUNNERS = {"stdio": _run_stdio, "http": _run_http}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_server(server: FastMCP, settings: ServerSettings) -> None:
|
|
69
|
+
"""Select a transport without branching inside any EAL operation."""
|
|
70
|
+
_RUNNERS[settings.transport](server, settings)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
settings = parse_server_settings()
|
|
75
|
+
run_server(build_server(settings), settings)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
eal/server_auth.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Authenticate HTTP callers within one operator-configured EAL trust domain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
|
|
9
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
10
|
+
|
|
11
|
+
from .credentials import read_bearer_token, validate_bearer_token
|
|
12
|
+
from .server_settings import ServerSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BearerTokenVerifier(TokenVerifier):
|
|
16
|
+
"""Verify an opaque token while retaining only its SHA-256 digest."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, secret: str):
|
|
19
|
+
validate_bearer_token(secret)
|
|
20
|
+
super().__init__()
|
|
21
|
+
self._token_digest = hashlib.sha256(secret.encode("utf-8")).digest()
|
|
22
|
+
|
|
23
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
24
|
+
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
|
25
|
+
if not hmac.compare_digest(digest, self._token_digest):
|
|
26
|
+
return None
|
|
27
|
+
return AccessToken(token=token, client_id="eal-operator", subject="eal-operator",
|
|
28
|
+
scopes=["eal:invoke"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def configured_auth(settings: ServerSettings, *,
|
|
32
|
+
environ: Mapping[str, str] | None = None) -> TokenVerifier | None:
|
|
33
|
+
"""Read a configured credential without adding its value to settings or diagnostics."""
|
|
34
|
+
if settings.transport != "http" or settings.token_env is None:
|
|
35
|
+
return None
|
|
36
|
+
secret = read_bearer_token(settings.token_env, environ)
|
|
37
|
+
return BearerTokenVerifier(secret)
|
eal/server_settings.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Trusted host settings for the configurable MCP server.
|
|
2
|
+
|
|
3
|
+
Precedence is CLI > ``EAL_MCP_*`` environment > TOML file > defaults. A config
|
|
4
|
+
file is selected by ``--config`` or ``EAL_MCP_CONFIG``. File paths are relative
|
|
5
|
+
to that file's parent; CLI and environment paths are relative to the current
|
|
6
|
+
directory. ``EAL_MCP_KNOWN_ENTRIES`` is a JSON array of registered identifiers.
|
|
7
|
+
An explicit exposure must agree with the selected entries. Otherwise non-empty
|
|
8
|
+
entries select registered exposure and an empty selection selects operator
|
|
9
|
+
exposure. Registered exposure always requires at least one entry.
|
|
10
|
+
|
|
11
|
+
Settings contain the token environment variable's name, never its value. Only
|
|
12
|
+
the application composition root reads credentials. This module imports no
|
|
13
|
+
server framework and creates no service, database, collector or network socket.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
import ipaddress
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
import re
|
|
26
|
+
import tomllib
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_SERVICE_KEYS = frozenset({"workspace", "registry", "database", "methods", "limits", "known_entries"})
|
|
32
|
+
_SERVER_KEYS = frozenset({"transport", "exposure", "max_in_flight", "http"})
|
|
33
|
+
_HTTP_KEYS = frozenset({"host", "port", "path", "token_env", "allowed_hosts", "allowed_origins"})
|
|
34
|
+
_PATH_KEYS = frozenset({"workspace", "registry", "database", "limits"})
|
|
35
|
+
_ENV_KEYS = _SERVICE_KEYS | (_SERVER_KEYS - {"http"}) | _HTTP_KEYS
|
|
36
|
+
_METHOD_FACTORY = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", re.ASCII)
|
|
37
|
+
_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
38
|
+
_HOST_NAME = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\.?", re.ASCII)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(value: Any, name: str) -> str:
|
|
42
|
+
if not isinstance(value, str) or not value or value != value.strip():
|
|
43
|
+
raise ValueError(f"{name} must be a non-empty string without surrounding whitespace")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _loopback_host(host: str) -> bool:
|
|
48
|
+
if host.lower().rstrip(".") == "localhost":
|
|
49
|
+
return True
|
|
50
|
+
try:
|
|
51
|
+
return ipaddress.ip_address(host).is_loopback
|
|
52
|
+
except ValueError:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _host(value: Any, name: str) -> str:
|
|
57
|
+
value = _text(value, name)
|
|
58
|
+
try:
|
|
59
|
+
ipaddress.ip_address(value)
|
|
60
|
+
except ValueError:
|
|
61
|
+
if not _HOST_NAME.fullmatch(value):
|
|
62
|
+
raise ValueError(f"{name} must be an IP address or hostname without a URL, port or wildcard")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _origin(value: Any) -> str:
|
|
67
|
+
value = _text(value, "allowed_origins")
|
|
68
|
+
try:
|
|
69
|
+
parsed = urlsplit(value)
|
|
70
|
+
port = parsed.port
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise ValueError("allowed_origins must contain HTTP(S) origins without paths or wildcards") from exc
|
|
73
|
+
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
|
|
74
|
+
or parsed.username is not None or parsed.password is not None
|
|
75
|
+
or parsed.path or parsed.query or parsed.fragment
|
|
76
|
+
or (port is not None and not 1 <= port <= 65535)
|
|
77
|
+
or any(character.isspace() for character in value)):
|
|
78
|
+
raise ValueError("allowed_origins must contain HTTP(S) origins without paths or wildcards")
|
|
79
|
+
_host(parsed.hostname, "allowed_origins host")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ServerSettings:
|
|
85
|
+
"""Validated immutable service and transport configuration."""
|
|
86
|
+
|
|
87
|
+
workspace: Path = field(default_factory=Path.cwd)
|
|
88
|
+
registry: Path | None = None
|
|
89
|
+
database: Path | None = None
|
|
90
|
+
methods: str | None = None
|
|
91
|
+
limits: Path | None = None
|
|
92
|
+
known_entries: tuple[str, ...] = ()
|
|
93
|
+
exposure: Literal["operator", "registered"] = "operator"
|
|
94
|
+
transport: Literal["stdio", "http"] = "stdio"
|
|
95
|
+
host: str = "127.0.0.1"
|
|
96
|
+
port: int = 8000
|
|
97
|
+
path: str = "/mcp"
|
|
98
|
+
max_in_flight: int = 1
|
|
99
|
+
token_env: str | None = None
|
|
100
|
+
allowed_hosts: tuple[str, ...] = ()
|
|
101
|
+
allowed_origins: tuple[str, ...] = ()
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
for name in _PATH_KEYS:
|
|
105
|
+
value = getattr(self, name)
|
|
106
|
+
if value is None and name != "workspace":
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(value, Path):
|
|
109
|
+
raise ValueError(f"{name} must be a filesystem path")
|
|
110
|
+
object.__setattr__(self, name, value.expanduser().resolve())
|
|
111
|
+
if self.methods is not None and not _METHOD_FACTORY.fullmatch(_text(self.methods, "methods")):
|
|
112
|
+
raise ValueError("methods must have the form package.module:function")
|
|
113
|
+
if _text(self.transport, "transport") not in {"stdio", "http"}:
|
|
114
|
+
raise ValueError("transport must be stdio or http")
|
|
115
|
+
if _text(self.exposure, "exposure") not in {"operator", "registered"}:
|
|
116
|
+
raise ValueError("exposure must be operator or registered")
|
|
117
|
+
if (not isinstance(self.known_entries, tuple)
|
|
118
|
+
or len(self.known_entries) > 128
|
|
119
|
+
or any(not isinstance(item, str) or not item or item != item.strip()
|
|
120
|
+
for item in self.known_entries)
|
|
121
|
+
or len(set(self.known_entries)) != len(self.known_entries)):
|
|
122
|
+
raise ValueError("known_entries must contain up to 128 unique non-empty registered IDs")
|
|
123
|
+
if self.exposure == "registered" and not self.known_entries:
|
|
124
|
+
raise ValueError("registered exposure requires at least one known entry")
|
|
125
|
+
if self.exposure == "operator" and self.known_entries:
|
|
126
|
+
raise ValueError("operator exposure conflicts with known entries")
|
|
127
|
+
if type(self.port) is not int or not 1 <= self.port <= 65535:
|
|
128
|
+
raise ValueError("port must be an integer from 1 through 65535")
|
|
129
|
+
if type(self.max_in_flight) is not int or not 1 <= self.max_in_flight <= 1024:
|
|
130
|
+
raise ValueError("max_in_flight must be an integer from 1 through 1024")
|
|
131
|
+
host = _host(self.host, "host")
|
|
132
|
+
for name in ("allowed_hosts", "allowed_origins"):
|
|
133
|
+
values = getattr(self, name)
|
|
134
|
+
if not isinstance(values, tuple) or len(values) > 128:
|
|
135
|
+
raise ValueError(f"{name} must contain up to 128 unique entries")
|
|
136
|
+
for value in values:
|
|
137
|
+
if name == "allowed_hosts":
|
|
138
|
+
_host(value, name)
|
|
139
|
+
else:
|
|
140
|
+
_origin(value)
|
|
141
|
+
if len(set(values)) != len(values):
|
|
142
|
+
raise ValueError(f"{name} must contain up to 128 unique entries")
|
|
143
|
+
path = _text(self.path, "path")
|
|
144
|
+
if (not path.startswith("/") or path.startswith("//")
|
|
145
|
+
or any(character.isspace() or ord(character) < 32 for character in path)
|
|
146
|
+
or any(character in path for character in "?#\\")
|
|
147
|
+
or any(part in {".", ".."} for part in path.split("/"))):
|
|
148
|
+
raise ValueError("path must be an absolute HTTP path without query, fragment or traversal")
|
|
149
|
+
if self.token_env is not None and not _ENV_NAME.fullmatch(_text(self.token_env, "token_env")):
|
|
150
|
+
raise ValueError("token_env must name an environment variable")
|
|
151
|
+
if self.transport == "http" and not _loopback_host(host) and self.token_env is None:
|
|
152
|
+
raise ValueError("HTTP on a non-loopback host requires token_env authentication")
|
|
153
|
+
if (self.transport == "http" and self.token_env is None
|
|
154
|
+
and (any(not _loopback_host(item) for item in self.allowed_hosts)
|
|
155
|
+
or any(not _loopback_host(urlsplit(item).hostname or "")
|
|
156
|
+
for item in self.allowed_origins))):
|
|
157
|
+
raise ValueError("HTTP with non-loopback allowed hosts or origins requires token_env authentication")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _table(value: Any, name: str, permitted: frozenset[str]) -> dict[str, Any]:
|
|
161
|
+
if not isinstance(value, dict):
|
|
162
|
+
raise ValueError(f"{name} must be a TOML table")
|
|
163
|
+
unknown = set(value) - permitted
|
|
164
|
+
if unknown:
|
|
165
|
+
raise ValueError(f"Unknown {name} settings: {', '.join(sorted(unknown))}")
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _read_config(path: Path) -> dict[str, Any]:
|
|
170
|
+
try:
|
|
171
|
+
with path.open("rb") as stream:
|
|
172
|
+
document = tomllib.load(stream)
|
|
173
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
174
|
+
raise ValueError(f"Cannot read server configuration {path}: {exc}") from exc
|
|
175
|
+
document = _table(document, "root", frozenset({"service", "server"}))
|
|
176
|
+
service = _table(document.get("service", {}), "service", _SERVICE_KEYS)
|
|
177
|
+
server = _table(document.get("server", {}), "server", _SERVER_KEYS)
|
|
178
|
+
http = _table(server.get("http", {}), "server.http", _HTTP_KEYS)
|
|
179
|
+
return {**service, **{name: value for name, value in server.items() if name != "http"}, **http}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_env(environ: Mapping[str, str], overridden: set[str]) -> dict[str, Any]:
|
|
183
|
+
values: dict[str, Any] = {}
|
|
184
|
+
for key in _ENV_KEYS:
|
|
185
|
+
name = "EAL_MCP_" + key.upper()
|
|
186
|
+
if name not in environ or key in overridden:
|
|
187
|
+
continue
|
|
188
|
+
raw = environ[name]
|
|
189
|
+
if key in {"known_entries", "allowed_hosts", "allowed_origins"}:
|
|
190
|
+
if not isinstance(raw, str):
|
|
191
|
+
raise ValueError(f"{name} must be a JSON array")
|
|
192
|
+
try:
|
|
193
|
+
values[key] = json.loads(raw)
|
|
194
|
+
except json.JSONDecodeError as exc:
|
|
195
|
+
raise ValueError(f"{name} must be a JSON array") from exc
|
|
196
|
+
continue
|
|
197
|
+
raw = _text(raw, name)
|
|
198
|
+
if key in {"port", "max_in_flight"}:
|
|
199
|
+
if not re.fullmatch(r"[+-]?[0-9]+", raw):
|
|
200
|
+
raise ValueError(f"{name} must be an integer")
|
|
201
|
+
values[key] = int(raw)
|
|
202
|
+
else:
|
|
203
|
+
values[key] = raw
|
|
204
|
+
return values
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parser() -> argparse.ArgumentParser:
|
|
208
|
+
parser = argparse.ArgumentParser(description="EAL MCP server with stdio or Streamable HTTP transport",
|
|
209
|
+
argument_default=argparse.SUPPRESS)
|
|
210
|
+
parser.add_argument("--config", help="Trusted server TOML configuration")
|
|
211
|
+
for name in ("workspace", "registry", "database", "limits"):
|
|
212
|
+
parser.add_argument("--" + name)
|
|
213
|
+
parser.add_argument("--methods", help="Trusted method-registry factory: package.module:function")
|
|
214
|
+
parser.add_argument("--known-entry", dest="known_entries", action="append", metavar="ENTRY_ID",
|
|
215
|
+
help="Expose a registered source; repeat for each permitted entry")
|
|
216
|
+
parser.add_argument("--transport", choices=("stdio", "http"))
|
|
217
|
+
parser.add_argument("--exposure", choices=("operator", "registered"))
|
|
218
|
+
parser.add_argument("--host")
|
|
219
|
+
parser.add_argument("--port", type=int)
|
|
220
|
+
parser.add_argument("--path")
|
|
221
|
+
parser.add_argument("--max-in-flight", type=int)
|
|
222
|
+
parser.add_argument("--token-env", help="Environment variable containing the HTTP bearer token")
|
|
223
|
+
parser.add_argument("--allowed-host", dest="allowed_hosts", action="append", metavar="HOST",
|
|
224
|
+
help="Additional trusted HTTP Host hostname or IP address; repeat per host")
|
|
225
|
+
parser.add_argument("--allowed-origin", dest="allowed_origins", action="append", metavar="ORIGIN",
|
|
226
|
+
help="Additional trusted HTTP(S) browser origin; repeat per origin")
|
|
227
|
+
return parser
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def parse_server_settings(argv: Sequence[str] | None = None,
|
|
231
|
+
environ: Mapping[str, str] | None = None) -> ServerSettings:
|
|
232
|
+
"""Resolve trusted settings without loading service code or reading tokens.
|
|
233
|
+
|
|
234
|
+
CLI errors follow argparse's standard behaviour; invalid file, environment
|
|
235
|
+
or resolved settings raise ``ValueError``. Known entries are replaced as a
|
|
236
|
+
collection at each precedence level. Relative paths retain their source's
|
|
237
|
+
base directory even when other values are overridden.
|
|
238
|
+
"""
|
|
239
|
+
environ = os.environ if environ is None else environ
|
|
240
|
+
cli = vars(_parser().parse_args(argv))
|
|
241
|
+
config_name = cli.pop("config", environ.get("EAL_MCP_CONFIG"))
|
|
242
|
+
cwd = Path.cwd()
|
|
243
|
+
config_path = (cwd / Path(_text(config_name, "config")).expanduser()).resolve() if config_name is not None else None
|
|
244
|
+
file_values = _read_config(config_path) if config_path is not None else {}
|
|
245
|
+
env_values = _parse_env(environ, set(cli))
|
|
246
|
+
values = {**file_values, **env_values, **cli}
|
|
247
|
+
for name in _PATH_KEYS & values.keys():
|
|
248
|
+
raw = _text(values[name], name)
|
|
249
|
+
base = config_path.parent if name in file_values and name not in env_values and name not in cli else cwd
|
|
250
|
+
values[name] = (base / Path(raw).expanduser()).resolve()
|
|
251
|
+
for name in {"known_entries", "allowed_hosts", "allowed_origins"} & values.keys():
|
|
252
|
+
selected = values[name]
|
|
253
|
+
if not isinstance(selected, list):
|
|
254
|
+
raise ValueError(f"{name} must be an array")
|
|
255
|
+
values[name] = tuple(selected)
|
|
256
|
+
if "exposure" not in values:
|
|
257
|
+
values["exposure"] = "registered" if values.get("known_entries") else "operator"
|
|
258
|
+
return ServerSettings(**values)
|
eal/source_printer.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Canonical printing of retained lexical structure without flattening templates."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from .model import (Environment, Tool, Evidence, Assumption, Reasoning, Claim, Argument,
|
|
6
|
+
Pattern, Application, Objection, ArgumentationDirective, Context,
|
|
7
|
+
Module, SourceImport, ArgumentBlock, PatternGuard)
|
|
8
|
+
from .expressions import format_expression, predicate_expression
|
|
9
|
+
from .parser import DEFAULT_FIELDS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def encode(value):
|
|
13
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(', ', ': '))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expression(predicate):
|
|
17
|
+
return format_expression(predicate_expression(predicate), encode)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def support(value, kinds=('evidence', 'assumptions', 'premises')):
|
|
21
|
+
return '[' + ', '.join(kind + ' ' + ', '.join(getattr(value, kind))
|
|
22
|
+
for kind in kinds if getattr(value, kind)) + ']'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def flow(value):
|
|
26
|
+
return support(value) + f' via {value.reasoning} => {value.conclusion}' + (
|
|
27
|
+
f' binding {value.binding}' if value.binding is not None else '')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def wrap(header, lines):
|
|
31
|
+
return header + ' {\n' + '\n'.join(' ' + line if line else '' for line in lines) + '\n}'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def block(value, defaults=None):
|
|
35
|
+
return '\n\n'.join(declaration(item.value, defaults or {}) for item in value.declarations)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def declaration(value, defaults):
|
|
39
|
+
if isinstance(value, Context):
|
|
40
|
+
ref_keys = {'environment', 'tool', 'kind', 'validate'}
|
|
41
|
+
attributes = ', '.join(key + ' ' + (item if key in ref_keys else encode(item))
|
|
42
|
+
for key, item in value.defaults.items())
|
|
43
|
+
return wrap('context ' + attributes, block(value.body, defaults | value.defaults).splitlines())
|
|
44
|
+
if isinstance(value, Module):
|
|
45
|
+
return wrap('module ' + value.name, block(value.body, defaults).splitlines())
|
|
46
|
+
if isinstance(value, SourceImport):
|
|
47
|
+
return 'import ' + encode(value.path) + ' as ' + value.name
|
|
48
|
+
if isinstance(value, ArgumentBlock):
|
|
49
|
+
lines = block(value.body, defaults).splitlines()
|
|
50
|
+
if lines:
|
|
51
|
+
lines.append('')
|
|
52
|
+
return wrap('argument ' + value.name, lines + [flow(value.conclusion)])
|
|
53
|
+
if isinstance(value, PatternGuard):
|
|
54
|
+
text = wrap('when ' + value.parameter, block(value.body, defaults).splitlines())
|
|
55
|
+
if value.otherwise is not None:
|
|
56
|
+
text += ' ' + wrap('else', block(value.otherwise, defaults).splitlines())
|
|
57
|
+
return text
|
|
58
|
+
if isinstance(value, Pattern):
|
|
59
|
+
parameters = ', '.join(f'{item.name}: {item.kind}' for item in value.parameters)
|
|
60
|
+
header = 'pattern ' + value.name + '(' + parameters + ')'
|
|
61
|
+
if value.decreases:
|
|
62
|
+
header += ' decreases ' + value.decreases
|
|
63
|
+
return wrap(header, block(value.body, defaults).splitlines()) if value.body else header + ' = ' + flow(value)
|
|
64
|
+
if isinstance(value, Application):
|
|
65
|
+
bindings = ', '.join(item.name + '=' + ('[' + ', '.join(item.reference) + ']'
|
|
66
|
+
if type(item.reference) is tuple else item.reference)
|
|
67
|
+
for item in value.arguments)
|
|
68
|
+
return f'apply {value.name} = {value.pattern}({bindings})'
|
|
69
|
+
if isinstance(value, Argument):
|
|
70
|
+
return 'argument ' + value.name + ' = ' + flow(value)
|
|
71
|
+
if isinstance(value, Objection):
|
|
72
|
+
return 'objection ' + value.name + ' = ' + support(value, ('evidence', 'premises')) + f' -x> {value.target_kind} {value.target}'
|
|
73
|
+
if isinstance(value, ArgumentationDirective):
|
|
74
|
+
text = value.kind + ' ' + value.name
|
|
75
|
+
if value.kind == 'rank':
|
|
76
|
+
text += ' ' + str(value.rank)
|
|
77
|
+
elif value.kind == 'contrary':
|
|
78
|
+
text += ' to ' + value.other
|
|
79
|
+
elif value.kind == 'prefer':
|
|
80
|
+
text += ' over ' + value.other
|
|
81
|
+
return text + ' reviewed ' + encode(value.review)
|
|
82
|
+
|
|
83
|
+
kind = {Environment: 'environment', Tool: 'tool', Evidence: 'evidence',
|
|
84
|
+
Assumption: 'assumption', Reasoning: 'reasoning', Claim: 'claim'}[type(value)]
|
|
85
|
+
lines = []
|
|
86
|
+
|
|
87
|
+
def field(name, item, quoted=False):
|
|
88
|
+
if name in DEFAULT_FIELDS.get(kind, ()) and name in defaults and encode(item) == encode(defaults[name]):
|
|
89
|
+
return
|
|
90
|
+
lines.append(name + ' ' + (encode(item) if quoted else str(item)))
|
|
91
|
+
|
|
92
|
+
if isinstance(value, Tool):
|
|
93
|
+
field('version', value.version, True)
|
|
94
|
+
if isinstance(value, Evidence):
|
|
95
|
+
field('tool', value.tool)
|
|
96
|
+
field('kind', value.kind)
|
|
97
|
+
field('environment', value.environment)
|
|
98
|
+
field('max_age', value.max_age, True)
|
|
99
|
+
field('input', value.input, True)
|
|
100
|
+
if isinstance(value, (Claim, Assumption)):
|
|
101
|
+
field('statement', value.statement, True)
|
|
102
|
+
field('environment', value.environment)
|
|
103
|
+
if isinstance(value, Assumption):
|
|
104
|
+
field('validate', value.validation)
|
|
105
|
+
for name in ('valid_from', 'valid_until'):
|
|
106
|
+
if getattr(value, name) is not None:
|
|
107
|
+
field(name, getattr(value, name), True)
|
|
108
|
+
if isinstance(value, Reasoning):
|
|
109
|
+
field('method', value.method, True)
|
|
110
|
+
field('rationale', value.rationale, True)
|
|
111
|
+
if value.backing:
|
|
112
|
+
lines.append('backing [' + ', '.join(value.backing) + ']')
|
|
113
|
+
if value.transfer:
|
|
114
|
+
relation = value.transfer
|
|
115
|
+
lines.append(f'transfer from {relation.source} to {relation.target} assuming {relation.assumption} reviewed ' + encode(relation.review))
|
|
116
|
+
if isinstance(value, (Environment, Evidence, Reasoning)):
|
|
117
|
+
lines.extend('require ' + expression(predicate) for predicate in value.predicates)
|
|
118
|
+
if isinstance(value, Claim) and value.proposition:
|
|
119
|
+
proposition = value.proposition
|
|
120
|
+
properties = [name + ' ' + encode(getattr(proposition, name))
|
|
121
|
+
for name in ('subject', 'quantity', 'unit', 'scope', 'valid_from', 'valid_until', 'query')
|
|
122
|
+
if name not in defaults or encode(getattr(proposition, name)) != encode(defaults[name])]
|
|
123
|
+
properties.append('result ' + expression(proposition.result))
|
|
124
|
+
lines.extend(wrap('proposition', properties).splitlines())
|
|
125
|
+
return wrap(kind + ' ' + value.name, lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def print_program(program):
|
|
129
|
+
return 'language ' + encode(program.language) + '\n\n' + block(program.authored) + '\n'
|