messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Opaque session tokens: the client holds the secret; the store keeps only its SHA-256.
|
|
4
|
+
|
|
5
|
+
Opaque server-side tokens (not JWT) are chosen so logout, expiry, and role changes take effect
|
|
6
|
+
immediately — the ``sessions`` row is the source of truth and can be revoked. Only the hash is
|
|
7
|
+
persisted, so reading the store never exposes a usable token.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import secrets
|
|
14
|
+
|
|
15
|
+
#: Bytes of entropy per token (urlsafe-base64 encoded to ~43 chars).
|
|
16
|
+
_TOKEN_BYTES = 32
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mint_token() -> str:
|
|
20
|
+
"""Return a fresh, unguessable token to hand to the client (store only its :func:`hash_token`)."""
|
|
21
|
+
return secrets.token_urlsafe(_TOKEN_BYTES)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_token(token: str) -> str:
|
|
25
|
+
"""Hash a token for storage/lookup. Plain SHA-256 is sufficient: tokens are high-entropy secrets."""
|
|
26
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""RFC 6238 TOTP — the local-account second factor (WP-14, ADR 0002 §3).
|
|
4
|
+
|
|
5
|
+
Pure standard-library crypto (``hmac`` / ``hashlib`` / ``base64`` / ``secrets``) so MFA adds **no new
|
|
6
|
+
dependency**: a software authenticator app (Google/Microsoft Authenticator, Authy, 1Password) and the
|
|
7
|
+
engine independently compute the same short code from a shared base32 secret plus the current
|
|
8
|
+
30-second time step. Used only for **local** users — AD/Kerberos MFA is delegated to the directory
|
|
9
|
+
(see :class:`~messagefoundry.auth.service.AuthService`). This module is side-effect-free and unit-
|
|
10
|
+
tested against the RFC 6238 vectors; it never touches the store, the event loop, or config.
|
|
11
|
+
|
|
12
|
+
Security notes:
|
|
13
|
+
|
|
14
|
+
- TOTP is a *shared-secret* factor: it satisfies OWASP ASVS 5.0 **6.3.3** at L2 but is phishable and
|
|
15
|
+
replayable inside its step window. The L3 preference for phishing-resistant factors (WebAuthn /
|
|
16
|
+
FIDO2) is the **WP-14b** follow-on, not implemented here.
|
|
17
|
+
- Verification uses a constant-time compare (:func:`hmac.compare_digest`) over a fixed candidate set
|
|
18
|
+
and a small clock-skew window. The secret is high-entropy (160-bit); the caller stores it encrypted
|
|
19
|
+
at rest (the store cipher) and never logs it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import secrets
|
|
28
|
+
import time
|
|
29
|
+
from urllib.parse import quote, urlencode
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_DIGITS",
|
|
33
|
+
"DEFAULT_PERIOD",
|
|
34
|
+
"DEFAULT_WINDOW",
|
|
35
|
+
"generate_secret",
|
|
36
|
+
"totp",
|
|
37
|
+
"verify_totp",
|
|
38
|
+
"verify_totp_step",
|
|
39
|
+
"otpauth_uri",
|
|
40
|
+
"generate_recovery_codes",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: Authenticator-app conventions (what Google/Microsoft Authenticator assume by default).
|
|
44
|
+
DEFAULT_DIGITS = 6
|
|
45
|
+
DEFAULT_PERIOD = 30 # seconds per time step
|
|
46
|
+
#: ± steps of clock skew tolerated at verify time (one step each side ≈ 30 s).
|
|
47
|
+
DEFAULT_WINDOW = 1
|
|
48
|
+
|
|
49
|
+
_SECRET_BYTES = 20 # 160 bits — RFC 4226 recommends ≥ 128 bits, 160 for HMAC-SHA1
|
|
50
|
+
|
|
51
|
+
# Recovery codes: human-legible groups from an unambiguous alphabet (no 0/O/1/I/L confusion).
|
|
52
|
+
_RECOVERY_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
53
|
+
_RECOVERY_GROUP_LEN = 5
|
|
54
|
+
_RECOVERY_GROUPS = 3 # e.g. "K7QF2-9DMNA-3XZP4" → ~74 bits of entropy
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate_secret() -> str:
|
|
58
|
+
"""Return a fresh base32-encoded TOTP secret (no padding) to share with the authenticator app."""
|
|
59
|
+
return base64.b32encode(secrets.token_bytes(_SECRET_BYTES)).decode("ascii").rstrip("=")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _decode_secret(secret: str) -> bytes:
|
|
63
|
+
"""Decode a base32 secret tolerantly: ignore spaces/case and restore any stripped padding."""
|
|
64
|
+
cleaned = secret.strip().replace(" ", "").upper()
|
|
65
|
+
padding = "=" * (-len(cleaned) % 8)
|
|
66
|
+
return base64.b32decode(cleaned + padding, casefold=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _hotp(key: bytes, counter: int, digits: int) -> str:
|
|
70
|
+
"""RFC 4226 HOTP: HMAC-SHA1 over the 8-byte counter, dynamically truncated to ``digits`` decimals."""
|
|
71
|
+
mac = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest()
|
|
72
|
+
offset = mac[-1] & 0x0F
|
|
73
|
+
truncated = int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF
|
|
74
|
+
return str(truncated % (10**digits)).zfill(digits)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def totp(
|
|
78
|
+
secret: str,
|
|
79
|
+
*,
|
|
80
|
+
now: float | None = None,
|
|
81
|
+
period: int = DEFAULT_PERIOD,
|
|
82
|
+
digits: int = DEFAULT_DIGITS,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""The current RFC 6238 TOTP code for ``secret`` (base32) at ``now`` (defaults to the wall clock)."""
|
|
85
|
+
moment = time.time() if now is None else now
|
|
86
|
+
counter = int(moment // period)
|
|
87
|
+
return _hotp(_decode_secret(secret), counter, digits)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_totp_step(
|
|
91
|
+
secret: str,
|
|
92
|
+
code: str,
|
|
93
|
+
*,
|
|
94
|
+
now: float | None = None,
|
|
95
|
+
period: int = DEFAULT_PERIOD,
|
|
96
|
+
digits: int = DEFAULT_DIGITS,
|
|
97
|
+
window: int = DEFAULT_WINDOW,
|
|
98
|
+
) -> int | None:
|
|
99
|
+
"""Return the time-step counter ``code`` matches within ±``window`` steps of ``now``, or ``None``.
|
|
100
|
+
|
|
101
|
+
Unlike :func:`verify_totp` this returns *which* step matched, so the caller can enforce single-use
|
|
102
|
+
by rejecting a step that was already consumed — RFC 6238 codes are otherwise replayable inside
|
|
103
|
+
their ~30 s step window (ASVS 6.5.1). Constant-time compared over a fixed set of candidate steps
|
|
104
|
+
(no early break) so a match near the window edge can't be distinguished by timing; a non-numeric
|
|
105
|
+
or wrong-length ``code`` returns ``None``.
|
|
106
|
+
"""
|
|
107
|
+
candidate = code.strip()
|
|
108
|
+
if len(candidate) != digits or not candidate.isdigit():
|
|
109
|
+
return None
|
|
110
|
+
moment = time.time() if now is None else now
|
|
111
|
+
key = _decode_secret(secret)
|
|
112
|
+
counter = int(moment // period)
|
|
113
|
+
matched: int | None = None
|
|
114
|
+
for step in range(counter - window, counter + window + 1):
|
|
115
|
+
if step < 0:
|
|
116
|
+
continue
|
|
117
|
+
if hmac.compare_digest(_hotp(key, step, digits), candidate):
|
|
118
|
+
matched = step
|
|
119
|
+
return matched
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def verify_totp(
|
|
123
|
+
secret: str,
|
|
124
|
+
code: str,
|
|
125
|
+
*,
|
|
126
|
+
now: float | None = None,
|
|
127
|
+
period: int = DEFAULT_PERIOD,
|
|
128
|
+
digits: int = DEFAULT_DIGITS,
|
|
129
|
+
window: int = DEFAULT_WINDOW,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""True iff ``code`` matches the TOTP for ``secret`` within ±``window`` steps of ``now``.
|
|
132
|
+
|
|
133
|
+
A thin bool wrapper over :func:`verify_totp_step` (which also reports *which* step matched, for
|
|
134
|
+
single-use enforcement). Constant-time; rejects a non-numeric or wrong-length ``code`` outright.
|
|
135
|
+
"""
|
|
136
|
+
return (
|
|
137
|
+
verify_totp_step(secret, code, now=now, period=period, digits=digits, window=window)
|
|
138
|
+
is not None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def otpauth_uri(
|
|
143
|
+
secret: str,
|
|
144
|
+
account: str,
|
|
145
|
+
*,
|
|
146
|
+
issuer: str = "MessageFoundry",
|
|
147
|
+
period: int = DEFAULT_PERIOD,
|
|
148
|
+
digits: int = DEFAULT_DIGITS,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Build the ``otpauth://totp/…`` URI an authenticator app scans (the UI renders it as a QR code)."""
|
|
151
|
+
# The "issuer:account" colon is the conventional literal label separator (keep it; encode the rest).
|
|
152
|
+
label = quote(f"{issuer}:{account}", safe=":")
|
|
153
|
+
params = urlencode(
|
|
154
|
+
{
|
|
155
|
+
"secret": secret,
|
|
156
|
+
"issuer": issuer,
|
|
157
|
+
"algorithm": "SHA1",
|
|
158
|
+
"digits": digits,
|
|
159
|
+
"period": period,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
return f"otpauth://totp/{label}?{params}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def generate_recovery_codes(count: int) -> list[str]:
|
|
166
|
+
"""Return ``count`` fresh, single-use recovery codes in plaintext (the caller hashes before storing)."""
|
|
167
|
+
codes: list[str] = []
|
|
168
|
+
for _ in range(count):
|
|
169
|
+
groups = [
|
|
170
|
+
"".join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(_RECOVERY_GROUP_LEN))
|
|
171
|
+
for _ in range(_RECOVERY_GROUPS)
|
|
172
|
+
]
|
|
173
|
+
codes.append("-".join(groups))
|
|
174
|
+
return codes
|
messagefoundry/checks.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""The ``messagefoundry check`` commit/CI gate — one callable for the git hook and the IDE.
|
|
4
|
+
|
|
5
|
+
``run_checks`` runs the project's checks against a config directory and reports a clear pass/fail,
|
|
6
|
+
reusing the in-process ``validate``/``dry_run`` paths (no re-shelling for the MessageFoundry-native
|
|
7
|
+
checks). Two checks are **required** (they can block a commit):
|
|
8
|
+
|
|
9
|
+
* ``validate`` — every config module loads and every ``inbound → router`` reference resolves.
|
|
10
|
+
* ``dryrun`` — *only when* a fixtures dir with ``*.hl7`` is given (searched recursively): each message
|
|
11
|
+
routes through its inbound's Router/Handler(s) without erroring. A fixture under a
|
|
12
|
+
``<messages>/<inbound_name>/`` subdir is dry-run **only** against that feed (#11); a fixture not under
|
|
13
|
+
such a subdir runs against **every** inbound. Absent fixtures → skipped (never blocks).
|
|
14
|
+
|
|
15
|
+
``ruff`` and ``mypy`` are **advisory**: run only when installed (``shutil.which``) and never block —
|
|
16
|
+
a non-developer author shouldn't be stopped by a lint nit. Exit-code policy lives in the CLI
|
|
17
|
+
(``__main__._check``): 0 iff no required check failed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = ["CheckResult", "CheckReport", "run_checks"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CheckResult:
|
|
33
|
+
"""The outcome of one check."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
ok: bool
|
|
37
|
+
required: bool
|
|
38
|
+
skipped: bool = False
|
|
39
|
+
detail: str = ""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def blocking(self) -> bool:
|
|
43
|
+
"""A required check that ran and failed — the only thing that fails the gate."""
|
|
44
|
+
return self.required and not self.ok and not self.skipped
|
|
45
|
+
|
|
46
|
+
def to_json(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"name": self.name,
|
|
49
|
+
"ok": self.ok,
|
|
50
|
+
"required": self.required,
|
|
51
|
+
"skipped": self.skipped,
|
|
52
|
+
"detail": self.detail,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class CheckReport:
|
|
58
|
+
"""All check outcomes for one run."""
|
|
59
|
+
|
|
60
|
+
results: list[CheckResult]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def ok(self) -> bool:
|
|
64
|
+
"""True unless a required check ran and failed."""
|
|
65
|
+
return not any(r.blocking for r in self.results)
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> dict[str, Any]:
|
|
68
|
+
return {"ok": self.ok, "checks": [r.to_json() for r in self.results]}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_checks(
|
|
72
|
+
config_dir: str | Path,
|
|
73
|
+
*,
|
|
74
|
+
messages_dir: str | Path | None = None,
|
|
75
|
+
run_lint: bool = True,
|
|
76
|
+
) -> CheckReport:
|
|
77
|
+
"""Run the gate against ``config_dir``; ``messages_dir`` enables the dry-run check when it has
|
|
78
|
+
fixtures. Set ``run_lint=False`` to skip the advisory ruff/mypy pass."""
|
|
79
|
+
results = [_check_validate(config_dir), _check_dryrun(config_dir, messages_dir)]
|
|
80
|
+
if run_lint:
|
|
81
|
+
results.append(_run_tool("ruff", ["ruff", "check", str(config_dir)]))
|
|
82
|
+
results.append(_run_tool("mypy", ["mypy", str(config_dir)]))
|
|
83
|
+
return CheckReport(results)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_validate(config_dir: str | Path) -> CheckResult:
|
|
87
|
+
from messagefoundry.config.wiring import validate_config
|
|
88
|
+
|
|
89
|
+
errors = [d for d in validate_config(config_dir) if d.severity == "error"]
|
|
90
|
+
if errors:
|
|
91
|
+
detail = f"{len(errors)} problem(s): " + "; ".join(
|
|
92
|
+
f"{d.file or '-'}: {d.message}" for d in errors[:5]
|
|
93
|
+
)
|
|
94
|
+
return CheckResult("validate", ok=False, required=True, detail=detail)
|
|
95
|
+
return CheckResult("validate", ok=True, required=True, detail="no problems")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_dryrun(config_dir: str | Path, messages_dir: str | Path | None) -> CheckResult:
|
|
99
|
+
from messagefoundry.config.wiring import WiringError, load_config
|
|
100
|
+
from messagefoundry.pipeline.dryrun import dry_run, read_message_sets
|
|
101
|
+
from messagefoundry.store import MessageStatus
|
|
102
|
+
|
|
103
|
+
if messages_dir is None:
|
|
104
|
+
return CheckResult(
|
|
105
|
+
"dryrun", ok=True, required=False, skipped=True, detail="no fixtures dir"
|
|
106
|
+
)
|
|
107
|
+
mpath = Path(messages_dir)
|
|
108
|
+
if not mpath.exists():
|
|
109
|
+
# An explicitly-given path that doesn't exist is a mistake (renamed/typo'd fixtures dir),
|
|
110
|
+
# not "no fixtures" — fail the gate rather than silently skip the required check (low-20).
|
|
111
|
+
return CheckResult(
|
|
112
|
+
"dryrun", ok=False, required=True, detail=f"messages path not found: {mpath}"
|
|
113
|
+
)
|
|
114
|
+
if mpath.is_dir() and not any(mpath.glob("**/*.hl7")):
|
|
115
|
+
# A real dir with no fixtures (searched recursively, since per-feed fixtures live in
|
|
116
|
+
# <messages>/<inbound>/ subdirs) is the documented "absent fixtures -> skipped" case. A single
|
|
117
|
+
# file (any extension) falls through and is dry-run like the `dryrun` CLI accepts (low-20).
|
|
118
|
+
return CheckResult(
|
|
119
|
+
"dryrun", ok=True, required=False, skipped=True, detail=f"no *.hl7 fixtures in {mpath}"
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
reg = load_config(config_dir)
|
|
123
|
+
except WiringError as exc:
|
|
124
|
+
# validate already reports (and blocks on) this — don't double-fail here.
|
|
125
|
+
return CheckResult(
|
|
126
|
+
"dryrun", ok=True, required=False, skipped=True, detail=f"config did not load: {exc}"
|
|
127
|
+
)
|
|
128
|
+
if not reg.inbound:
|
|
129
|
+
return CheckResult(
|
|
130
|
+
"dryrun", ok=True, required=False, skipped=True, detail="no inbound connections"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Per-feed mapping (#11): a fixture under <messages>/<inbound_name>/ is dry-run only against that
|
|
134
|
+
# feed; an unmapped fixture (top-level, or under a non-feed subdir) cross-products every inbound.
|
|
135
|
+
inbound_names = list(reg.inbound)
|
|
136
|
+
message_sets = read_message_sets(mpath, inbound_names)
|
|
137
|
+
errors: list[str] = []
|
|
138
|
+
total = 0
|
|
139
|
+
pinned = 0
|
|
140
|
+
for label, _path, raw, target in message_sets:
|
|
141
|
+
targets = [target] if target is not None else inbound_names
|
|
142
|
+
if target is not None:
|
|
143
|
+
pinned += 1
|
|
144
|
+
for ic_name in targets:
|
|
145
|
+
total += 1
|
|
146
|
+
result = dry_run(reg, raw, inbound=ic_name)
|
|
147
|
+
if result.error or result.disposition is MessageStatus.ERROR:
|
|
148
|
+
errors.append(f"{label} @ {ic_name}: {result.error or result.disposition.value}")
|
|
149
|
+
if errors:
|
|
150
|
+
detail = f"{len(errors)}/{total} run(s) errored: " + "; ".join(errors[:5])
|
|
151
|
+
return CheckResult("dryrun", ok=False, required=True, detail=detail)
|
|
152
|
+
pin_note = f", {pinned} feed-pinned" if pinned else ""
|
|
153
|
+
detail = f"{total} run(s) clean across {len(message_sets)} message(s){pin_note}"
|
|
154
|
+
return CheckResult("dryrun", ok=True, required=True, detail=detail)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_tool(name: str, cmd: list[str]) -> CheckResult:
|
|
158
|
+
"""Advisory: run ``cmd`` only if its executable resolves; absent → skipped, never blocking."""
|
|
159
|
+
if shutil.which(cmd[0]) is None:
|
|
160
|
+
return CheckResult(name, ok=True, required=False, skipped=True, detail="not installed")
|
|
161
|
+
try:
|
|
162
|
+
# nosec: cmd[0] is a fixed tool name (ruff/mypy), no shell; args are repo paths (low-27).
|
|
163
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=120) # nosec B603 B607
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
# A wedged advisory tool must not block a commit forever — degrade to a skip (low-21).
|
|
166
|
+
return CheckResult(name, ok=True, required=False, skipped=True, detail="timed out (120s)")
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
return CheckResult(
|
|
169
|
+
name, ok=True, required=False, skipped=True, detail=f"could not run: {exc}"
|
|
170
|
+
)
|
|
171
|
+
if proc.returncode == 0:
|
|
172
|
+
return CheckResult(name, ok=True, required=False, detail="passed")
|
|
173
|
+
detail = (proc.stdout or proc.stderr).strip().replace("\n", " ")[:300] or "failed"
|
|
174
|
+
return CheckResult(name, ok=False, required=False, detail=detail)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Configuration: connector models + the code-first wiring layer.
|
|
4
|
+
|
|
5
|
+
Connectors are described by :class:`Source`/:class:`Destination` (type + free-form
|
|
6
|
+
``settings``). The message graph is authored code-first in
|
|
7
|
+
:mod:`messagefoundry.config.wiring` — declare ``inbound``/``outbound`` Connections and
|
|
8
|
+
decorate ``@router``/``@handler`` scripts; a directory of such modules loads via
|
|
9
|
+
``load_config`` into a :class:`~messagefoundry.config.wiring.Registry`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from messagefoundry.config.models import (
|
|
15
|
+
AckMode,
|
|
16
|
+
ConnectorType,
|
|
17
|
+
Destination,
|
|
18
|
+
RetryPolicy,
|
|
19
|
+
Source,
|
|
20
|
+
Validation,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Source",
|
|
25
|
+
"Destination",
|
|
26
|
+
"Validation",
|
|
27
|
+
"RetryPolicy",
|
|
28
|
+
"ConnectorType",
|
|
29
|
+
"AckMode",
|
|
30
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""The active **environment** name, readable inside a transform (per-face logic).
|
|
4
|
+
|
|
5
|
+
A migrated feed sometimes branches on which deployment it is running as — e.g. stamp MSH-11 (Processing
|
|
6
|
+
Id) ``P`` in production vs ``T`` in test (Corepoint's ``If #Environment[ActiveFace]="Test"``). The
|
|
7
|
+
engine's single active-environment selector is ``[ai].environment`` / ``serve --env`` (a free-form
|
|
8
|
+
name, ADR 0017); this module makes that name readable synchronously inside a Router or Handler via
|
|
9
|
+
:func:`current_environment`.
|
|
10
|
+
|
|
11
|
+
It is shaped like :mod:`messagefoundry.config.state` / :mod:`messagefoundry.config.reference`: a
|
|
12
|
+
ContextVar the runner publishes around each router/transform run, read synchronously by the accessor.
|
|
13
|
+
Unlike ``env()`` — which is a *deferred reference* resolved only when a **connection** spec is built
|
|
14
|
+
(using it inside a handler is an always-truthy object, a bug) — :func:`current_environment` returns the
|
|
15
|
+
**string** name, usable in handler control flow.
|
|
16
|
+
|
|
17
|
+
**Re-run-safe.** The active environment is fixed for the life of the engine process (a crash-re-run
|
|
18
|
+
restarts in the same environment; a config reload swaps the graph, not the environment), so reading it
|
|
19
|
+
is deterministic — pure, and compatible with the staged-pipeline re-run invariant (ADR 0001 /
|
|
20
|
+
CLAUDE.md §2). Distinct from ``env()`` *values*, which may differ per environment but are resolved at
|
|
21
|
+
connection-build time, not in a transform.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Iterator
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from contextvars import ContextVar
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"current_environment",
|
|
33
|
+
"set_active",
|
|
34
|
+
"reset",
|
|
35
|
+
"activated",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# Active environment name as a ContextVar (mirrors state._active): the runner publishes it around each
|
|
39
|
+
# router/transform run so a call-time current_environment() inside a Handler resolves, and a clean
|
|
40
|
+
# reset restores the prior value (no leak across runs / tests). None = "no active environment" (outside
|
|
41
|
+
# a run / a dry-run with no live engine).
|
|
42
|
+
_active: ContextVar[str | None] = ContextVar("mefor_active_environment", default=None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_active(name: str | None) -> Any:
|
|
46
|
+
"""Publish ``name`` as the active environment and return a reset token (pass it to :func:`reset`)."""
|
|
47
|
+
return _active.set(name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reset(token: Any) -> None:
|
|
51
|
+
"""Restore the active environment to what it was before the matching :func:`set_active`."""
|
|
52
|
+
_active.reset(token)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def activated(name: str | None) -> Iterator[None]:
|
|
57
|
+
"""Make ``name`` the active environment for the duration of the ``with`` block, then restore.
|
|
58
|
+
|
|
59
|
+
The runner brackets each router/transform run with it, so :func:`current_environment` resolves at
|
|
60
|
+
call time and the prior value is always restored — no leak."""
|
|
61
|
+
token = _active.set(name)
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
_active.reset(token)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def current_environment() -> str | None:
|
|
69
|
+
"""The active environment name (a free-form string, e.g. ``"prod"``/``"test"``), or ``None`` outside a run.
|
|
70
|
+
|
|
71
|
+
Read it inside a Router/Handler for per-face logic::
|
|
72
|
+
|
|
73
|
+
# Corepoint: If ActiveFace="Test" Then MSH-11.1 = "T"
|
|
74
|
+
if current_environment() in ("staging", "dev"):
|
|
75
|
+
msg.set("MSH-11.1", "T")
|
|
76
|
+
|
|
77
|
+
Returns ``None`` in a dry-run / outside a run (no live engine) — a transform should default
|
|
78
|
+
sensibly (treat None as the production / leave-as-is case). The value is a deployment constant, so
|
|
79
|
+
the read is pure + re-run-safe (unlike a per-message external call)."""
|
|
80
|
+
return _active.get()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""AI-assistance **policy model** — the authoritative two-axis policy and its clamping algorithm.
|
|
4
|
+
|
|
5
|
+
A central operator governs how much AI coding assistance is permitted across a spectrum from
|
|
6
|
+
**OFF** to **PHI-safe**, expressed as two independent axes bounded by a posture ceiling:
|
|
7
|
+
|
|
8
|
+
* **mode** (:class:`AiMode`) — *whether and how* assistance runs: ``off`` (none), ``byo``
|
|
9
|
+
(the user's own provider; this extension version's only working path), or the engine-brokered
|
|
10
|
+
``managed_claude`` / ``managed_claude_baa`` (P1/P2 — not built here, but a policy may already
|
|
11
|
+
declare them so the IDE refuses rather than silently downgrading).
|
|
12
|
+
* **data_scope** (:class:`AiDataScope`) — *how sensitive* the context attached to a request may be,
|
|
13
|
+
ordered least→most sensitive: ``code_only`` < ``synthetic`` < ``deidentified`` < ``phi``.
|
|
14
|
+
|
|
15
|
+
The instance's **production** posture flag imposes a ceiling on ``data_scope`` so the same config
|
|
16
|
+
behaves conservatively on a non-production instance and only reaches ``phi`` on a production instance
|
|
17
|
+
under a BAA mode. ``mode`` itself is never clamped — a central ``off`` is honored everywhere. Posture
|
|
18
|
+
is **decoupled from the environment *name*** (ADR 0017): an instance is ``production`` (and/or
|
|
19
|
+
PHI-carrying, see :class:`DataClass`) regardless of whether it is literally named ``prod`` — so an
|
|
20
|
+
org can name instances ``poc``/``test``/… while choosing posture explicitly.
|
|
21
|
+
|
|
22
|
+
This module is **pure** (no I/O) and imports nothing from :mod:`messagefoundry.config.settings`
|
|
23
|
+
(the dependency is one-way: settings imports these enums, not the reverse, to avoid a cycle). It is
|
|
24
|
+
consumed by the ``GET /ai/policy`` API endpoint and the ``messagefoundry ai-policy`` CLI, which both
|
|
25
|
+
serialize :class:`EffectivePolicy` to the shared snake_case wire shape.
|
|
26
|
+
|
|
27
|
+
Note: the ``deidentified`` scope depends on a de-identification framework that **does not exist in
|
|
28
|
+
this repo** (roadmap only); :func:`resolve_effective_policy` therefore always clamps it down rather
|
|
29
|
+
than pretending it is reachable.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from enum import Enum
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AiMode(str, Enum):
|
|
39
|
+
"""Whether and how AI assistance runs. The value is the wire/storage string."""
|
|
40
|
+
|
|
41
|
+
OFF = "off" # no assistance at all
|
|
42
|
+
BYO = "byo" # bring-your-own provider (the only working path in this MVP)
|
|
43
|
+
MANAGED_CLAUDE = "managed_claude" # engine-brokered Claude (P1; not built here)
|
|
44
|
+
MANAGED_CLAUDE_BAA = "managed_claude_baa" # brokered Claude under a BAA (P2; unlocks phi scope)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AiDataScope(str, Enum):
|
|
48
|
+
"""How sensitive the context attached to an AI request may be. The value is the wire string."""
|
|
49
|
+
|
|
50
|
+
CODE_ONLY = "code_only" # graph names + editor code only (PHI-safe by construction)
|
|
51
|
+
SYNTHETIC = "synthetic" # plus synthetic/sample HL7
|
|
52
|
+
DEIDENTIFIED = "deidentified" # de-identified PHI (needs the unbuilt de-id framework)
|
|
53
|
+
PHI = "phi" # real message bodies (only over a BAA + zero-retention provider)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DataClass(str, Enum):
|
|
57
|
+
"""Whether an instance handles real PHI, **independent of its (free-form) environment name**.
|
|
58
|
+
|
|
59
|
+
Drives the at-rest-encryption + open-egress startup advisories (a synthetic instance stays quiet;
|
|
60
|
+
a ``phi`` instance is warned). The AI data-scope ceiling keys off the separate ``production`` flag,
|
|
61
|
+
not this. Decoupling the data class from the environment name (ADR 0017) lets an org name instances
|
|
62
|
+
freely (``poc``/``test``/…) while choosing posture explicitly. The value is the wire string."""
|
|
63
|
+
|
|
64
|
+
SYNTHETIC = "synthetic" # synthetic/sample data only — relaxed at-rest/egress posture
|
|
65
|
+
PHI = "phi" # carries real PHI — encryption + egress advisories apply
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: Scope ordering, least→most sensitive. Used to take the *lower* of (requested, ceiling).
|
|
69
|
+
_SCOPE_ORDER: dict[AiDataScope, int] = {
|
|
70
|
+
AiDataScope.CODE_ONLY: 0,
|
|
71
|
+
AiDataScope.SYNTHETIC: 1,
|
|
72
|
+
AiDataScope.DEIDENTIFIED: 2,
|
|
73
|
+
AiDataScope.PHI: 3,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class EffectivePolicy:
|
|
79
|
+
"""The clamped, enforceable policy returned by :func:`resolve_effective_policy`.
|
|
80
|
+
|
|
81
|
+
``reason`` is a human-readable, ``"; "``-joined note of every clamp applied (``None`` when the
|
|
82
|
+
requested policy passed through unchanged) — surfaced in the API/CLI so an operator can see *why*
|
|
83
|
+
the effective scope differs from what was configured. The environment *name* and the posture
|
|
84
|
+
(``data_class``/``production``) are carried by the caller's wire model, not here.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
mode: AiMode
|
|
88
|
+
data_scope: AiDataScope
|
|
89
|
+
reason: str | None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_effective_policy(
|
|
93
|
+
*, mode: AiMode, data_scope: AiDataScope, production: bool
|
|
94
|
+
) -> EffectivePolicy:
|
|
95
|
+
"""Clamp a requested (mode, data_scope) to the enforceable effective policy for this instance.
|
|
96
|
+
|
|
97
|
+
The algorithm (in order): apply the production-posture data-scope ceiling; defensively block
|
|
98
|
+
``phi`` unless the mode is BAA-managed; block ``deidentified`` (the de-id framework is unbuilt);
|
|
99
|
+
and normalize scope to ``code_only`` when the mode is ``off``. ``mode`` is never clamped — a
|
|
100
|
+
central ``off``/managed choice is honored regardless of posture. ``production`` is the instance's
|
|
101
|
+
posture flag (decoupled from the environment *name*, ADR 0017), not whether it is literally named
|
|
102
|
+
``prod``.
|
|
103
|
+
"""
|
|
104
|
+
reasons: list[str] = []
|
|
105
|
+
|
|
106
|
+
# 1. Posture data-scope ceiling. A non-production instance never exceeds synthetic; a production
|
|
107
|
+
# instance reaches phi only under a BAA-managed mode, otherwise it floors at code_only.
|
|
108
|
+
if production:
|
|
109
|
+
ceiling = AiDataScope.PHI if mode is AiMode.MANAGED_CLAUDE_BAA else AiDataScope.CODE_ONLY
|
|
110
|
+
else:
|
|
111
|
+
ceiling = AiDataScope.SYNTHETIC
|
|
112
|
+
|
|
113
|
+
eff = data_scope
|
|
114
|
+
if _SCOPE_ORDER[ceiling] < _SCOPE_ORDER[eff]:
|
|
115
|
+
eff = ceiling
|
|
116
|
+
tier = "production" if production else "non-production"
|
|
117
|
+
reasons.append(f"data scope capped to {eff.value} by a {tier} instance")
|
|
118
|
+
|
|
119
|
+
# 2. phi hard rule (defensive): phi requires a BAA-managed mode. The ceiling already enforces
|
|
120
|
+
# this on a production instance; this guards any path that reached phi outside it.
|
|
121
|
+
if eff is AiDataScope.PHI and mode is not AiMode.MANAGED_CLAUDE_BAA:
|
|
122
|
+
eff = AiDataScope.CODE_ONLY
|
|
123
|
+
reasons.append("phi scope requires managed_claude_baa mode; fell back to code_only")
|
|
124
|
+
|
|
125
|
+
# 3. deidentified hard rule: the de-id framework is roadmap only, so this scope is never live.
|
|
126
|
+
if eff is AiDataScope.DEIDENTIFIED:
|
|
127
|
+
eff = AiDataScope.CODE_ONLY
|
|
128
|
+
reasons.append(
|
|
129
|
+
"deidentified scope requires the (unbuilt) de-id framework; fell back to code_only"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# 4. off normalization: when AI is off the scope is irrelevant — pin it to the safe floor.
|
|
133
|
+
if mode is AiMode.OFF:
|
|
134
|
+
eff = AiDataScope.CODE_ONLY
|
|
135
|
+
|
|
136
|
+
return EffectivePolicy(
|
|
137
|
+
mode=mode,
|
|
138
|
+
data_scope=eff,
|
|
139
|
+
reason="; ".join(reasons) if reasons else None,
|
|
140
|
+
)
|