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,1155 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Command-line entrypoint for the MessageFoundry engine + IDE tooling.
|
|
4
|
+
|
|
5
|
+
messagefoundry serve --config ./samples/config --db ./messagefoundry.db # run engine + API
|
|
6
|
+
messagefoundry validate --config ./samples/config --json # report problems
|
|
7
|
+
messagefoundry graph --config ./samples/config --json # the wired graph
|
|
8
|
+
messagefoundry dryrun --config ./samples/config --messages ./msgs --json # run, don't send
|
|
9
|
+
messagefoundry check --config ./samples/config --messages ./msgs # commit/CI gate
|
|
10
|
+
messagefoundry connection upsert --config ./samples/config --data '{...}' # edit connections.toml
|
|
11
|
+
messagefoundry generate --type ADT --count 5 --out ./out/adt # synthetic HL7
|
|
12
|
+
messagefoundry hl7schema --json # HL7 field schema
|
|
13
|
+
messagefoundry init ./my-config-repo # scaffold a config repo
|
|
14
|
+
|
|
15
|
+
The introspection subcommands (validate/graph/dryrun/check/hl7schema) print to stdout for the VS
|
|
16
|
+
Code extension / git hooks; they touch no network and start no server. Heavy imports are deferred
|
|
17
|
+
per-command so a quick `validate`/`hl7schema` call doesn't pay for FastAPI/uvicorn.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import sys
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from messagefoundry import __version__
|
|
29
|
+
from messagefoundry.logging_setup import LOG_LEVELS, SyslogForward, configure_logging
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="messagefoundry", description=__doc__)
|
|
34
|
+
parser.add_argument("--version", action="version", version=f"messagefoundry {__version__}")
|
|
35
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
|
|
37
|
+
serve = sub.add_parser("serve", help="run the engine + localhost API")
|
|
38
|
+
serve.add_argument("--config", default="samples/config", help="config modules directory (*.py)")
|
|
39
|
+
serve.add_argument(
|
|
40
|
+
"--service-config",
|
|
41
|
+
default=None,
|
|
42
|
+
help="service settings TOML (default: ./messagefoundry.toml if present)",
|
|
43
|
+
)
|
|
44
|
+
# These override the corresponding settings; defaults live in ServiceSettings, not argparse, so
|
|
45
|
+
# precedence (CLI > env > file > default) is honored — an unset flag falls through.
|
|
46
|
+
serve.add_argument("--db", default=None, help="message store path (overrides [store].path)")
|
|
47
|
+
serve.add_argument("--host", default=None, help="API bind host (overrides [api].host)")
|
|
48
|
+
serve.add_argument(
|
|
49
|
+
"--port", type=int, default=None, help="API bind port (overrides [api].port)"
|
|
50
|
+
)
|
|
51
|
+
serve.add_argument(
|
|
52
|
+
"--log-level",
|
|
53
|
+
default=None,
|
|
54
|
+
choices=LOG_LEVELS,
|
|
55
|
+
help="logging verbosity (overrides [logging].level)",
|
|
56
|
+
)
|
|
57
|
+
serve.add_argument(
|
|
58
|
+
"--env",
|
|
59
|
+
default=None,
|
|
60
|
+
help="active environment NAME (overrides [ai].environment; selects environments/<env>.toml "
|
|
61
|
+
"values). Built-in names dev/staging/prod carry a default posture; a custom name also needs "
|
|
62
|
+
"[ai].data_class + [ai].production set.",
|
|
63
|
+
)
|
|
64
|
+
serve.add_argument(
|
|
65
|
+
"--project-root",
|
|
66
|
+
default=None,
|
|
67
|
+
help="anchor for the per-environment value dir (overrides [environments].base_dir): the "
|
|
68
|
+
"config-repo root that environments/<env>.toml resolves against. Default = the working "
|
|
69
|
+
"directory (unchanged). Set this when serve runs from elsewhere than the repo root (e.g. "
|
|
70
|
+
"under NSSM) so env() values aren't silently empty.",
|
|
71
|
+
)
|
|
72
|
+
serve.add_argument(
|
|
73
|
+
"--allow-insecure-bind",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="permit a non-loopback [api].host WITHOUT TLS (bearer tokens and PHI would cross the "
|
|
76
|
+
"network in cleartext); a dev override for a trusted, firewalled network. Prefer configuring "
|
|
77
|
+
"[api].tls_cert_file (+ tls_key_file) for in-process TLS, which is allowed off-loopback "
|
|
78
|
+
"without this flag. Does not relax the no-auth refuse.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
validate = sub.add_parser("validate", help="check a config dir and report all problems")
|
|
82
|
+
validate.add_argument("--config", default="samples/config", help="config modules directory")
|
|
83
|
+
validate.add_argument("--json", action="store_true", help="emit JSON")
|
|
84
|
+
|
|
85
|
+
graph = sub.add_parser("graph", help="print the wired Connection/Router/Handler graph")
|
|
86
|
+
graph.add_argument("--config", default="samples/config", help="config modules directory")
|
|
87
|
+
graph.add_argument("--json", action="store_true", help="emit JSON")
|
|
88
|
+
|
|
89
|
+
dryrun = sub.add_parser("dryrun", help="run messages through the config without sending")
|
|
90
|
+
dryrun.add_argument("--config", default="samples/config", help="config modules directory")
|
|
91
|
+
dryrun.add_argument(
|
|
92
|
+
"--messages", required=True, nargs="+", help="HL7 file(s) or directories of *.hl7"
|
|
93
|
+
)
|
|
94
|
+
dryrun.add_argument("--inbound", default=None, help="inbound connection to simulate")
|
|
95
|
+
dryrun.add_argument("--json", action="store_true", help="emit JSON")
|
|
96
|
+
dryrun.add_argument(
|
|
97
|
+
"--show-phi",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="include full message bodies (raw + payloads) — PHI; redacted by default",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
check = sub.add_parser(
|
|
103
|
+
"check", help="run validate + dryrun (+ advisory ruff/mypy) as a commit/CI gate"
|
|
104
|
+
)
|
|
105
|
+
check.add_argument("--config", default="samples/config", help="config modules directory")
|
|
106
|
+
check.add_argument(
|
|
107
|
+
"--messages", default=None, help="HL7 fixtures dir (dryrun gates when it has *.hl7)"
|
|
108
|
+
)
|
|
109
|
+
check.add_argument("--no-lint", action="store_true", help="skip the advisory ruff/mypy checks")
|
|
110
|
+
check.add_argument("--json", action="store_true", help="emit JSON")
|
|
111
|
+
|
|
112
|
+
connection = sub.add_parser(
|
|
113
|
+
"connection",
|
|
114
|
+
help="manage connections.toml — list / upsert / remove (ADR 0007; the VS Code editor shells this)",
|
|
115
|
+
)
|
|
116
|
+
connection.add_argument("action", choices=["list", "upsert", "remove"])
|
|
117
|
+
connection.add_argument("--config", default="samples/config", help="config modules directory")
|
|
118
|
+
connection.add_argument(
|
|
119
|
+
"--service-config",
|
|
120
|
+
default=None,
|
|
121
|
+
help="service settings TOML for [egress]/active-env validation (default: "
|
|
122
|
+
"./messagefoundry.toml if present)",
|
|
123
|
+
)
|
|
124
|
+
connection.add_argument("--name", default=None, help="connection name (for remove)")
|
|
125
|
+
connection.add_argument(
|
|
126
|
+
"--data", default=None, help="connection JSON for upsert (default: read from stdin)"
|
|
127
|
+
)
|
|
128
|
+
connection.add_argument("--json", action="store_true", help="emit JSON")
|
|
129
|
+
|
|
130
|
+
generate = sub.add_parser(
|
|
131
|
+
"generate", help="generate conformant synthetic HL7 messages (no real PHI)"
|
|
132
|
+
)
|
|
133
|
+
generate.add_argument("--type", default=None, help="message type, e.g. ADT, ORU (see --list)")
|
|
134
|
+
generate.add_argument(
|
|
135
|
+
"--triggers", default="", help="comma-separated subset (default: all for the type)"
|
|
136
|
+
)
|
|
137
|
+
generate.add_argument("--count", type=int, default=50, help="messages per trigger (default 50)")
|
|
138
|
+
generate.add_argument(
|
|
139
|
+
"--out", default=None, help="output root (default: samples/messages/<type>)"
|
|
140
|
+
)
|
|
141
|
+
generate.add_argument("--seed", default=None, help="RNG seed for reproducible output")
|
|
142
|
+
generate.add_argument("--list", action="store_true", help="list registered message types")
|
|
143
|
+
generate.add_argument("--json", action="store_true", help="emit JSON")
|
|
144
|
+
|
|
145
|
+
schema = sub.add_parser("hl7schema", help="print HL7 v2.5.1 segment/field schema")
|
|
146
|
+
schema.add_argument("--json", action="store_true", help="emit JSON")
|
|
147
|
+
|
|
148
|
+
init = sub.add_parser(
|
|
149
|
+
"init",
|
|
150
|
+
help="scaffold a new config repo (starter feed + environments + CI + a pinned engine)",
|
|
151
|
+
)
|
|
152
|
+
init.add_argument("dir", nargs="?", default=".", help="target directory (default: current dir)")
|
|
153
|
+
init.add_argument(
|
|
154
|
+
"--force",
|
|
155
|
+
action="store_true",
|
|
156
|
+
help="scaffold into a non-empty directory (existing files are left untouched)",
|
|
157
|
+
)
|
|
158
|
+
init.add_argument("--json", action="store_true", help="emit JSON")
|
|
159
|
+
|
|
160
|
+
sub.add_parser(
|
|
161
|
+
"gen-key", help="generate a base64 key for MEFOR_STORE_ENCRYPTION_KEY (PHI-at-rest)"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
protect_key = sub.add_parser(
|
|
165
|
+
"protect-key",
|
|
166
|
+
help="DPAPI-protect the store key to a file for [store].encryption_key_file (Windows-only)",
|
|
167
|
+
)
|
|
168
|
+
protect_key.add_argument("--out", required=True, help="path to write the protected key file")
|
|
169
|
+
protect_key.add_argument(
|
|
170
|
+
"--generate",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="mint a fresh key and protect it (printed once to stderr so you can back it up offline)",
|
|
173
|
+
)
|
|
174
|
+
protect_key.add_argument(
|
|
175
|
+
"--user",
|
|
176
|
+
action="store_true",
|
|
177
|
+
help="protect under the current USER only (default: machine scope, so the low-privilege "
|
|
178
|
+
"service account can read the key at startup)",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
audit_verify = sub.add_parser(
|
|
182
|
+
"audit-verify", help="verify the audit-log hash chain (tamper-evidence)"
|
|
183
|
+
)
|
|
184
|
+
audit_verify.add_argument(
|
|
185
|
+
"--service-config",
|
|
186
|
+
default=None,
|
|
187
|
+
help="service settings TOML (default: ./messagefoundry.toml if present)",
|
|
188
|
+
)
|
|
189
|
+
audit_verify.add_argument("--db", default=None, help="store path (overrides [store].path)")
|
|
190
|
+
|
|
191
|
+
rotate_key = sub.add_parser(
|
|
192
|
+
"rotate-key",
|
|
193
|
+
help="re-encrypt the store under the active MEFOR_STORE_ENCRYPTION_KEY (run with the engine "
|
|
194
|
+
"stopped; keep the prior key in MEFOR_STORE_ENCRYPTION_KEYS_RETIRED)",
|
|
195
|
+
)
|
|
196
|
+
rotate_key.add_argument(
|
|
197
|
+
"--service-config",
|
|
198
|
+
default=None,
|
|
199
|
+
help="service settings TOML (default: ./messagefoundry.toml if present)",
|
|
200
|
+
)
|
|
201
|
+
rotate_key.add_argument("--db", default=None, help="store path (overrides [store].path)")
|
|
202
|
+
|
|
203
|
+
ai_policy = sub.add_parser(
|
|
204
|
+
"ai-policy", help="print the effective AI-assistance policy (for the IDE gate)"
|
|
205
|
+
)
|
|
206
|
+
ai_policy.add_argument(
|
|
207
|
+
"--service-config",
|
|
208
|
+
default=None,
|
|
209
|
+
help="service settings TOML (default: ./messagefoundry.toml if present)",
|
|
210
|
+
)
|
|
211
|
+
ai_policy.add_argument("--json", action="store_true", help="emit JSON only (parsed by the IDE)")
|
|
212
|
+
|
|
213
|
+
args = parser.parse_args(argv)
|
|
214
|
+
return _DISPATCH[args.command](args)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
218
|
+
import uvicorn
|
|
219
|
+
from pydantic import ValidationError
|
|
220
|
+
|
|
221
|
+
from messagefoundry.api import create_managed_app
|
|
222
|
+
from messagefoundry.config.settings import StoreBackend, load_settings
|
|
223
|
+
|
|
224
|
+
# Only pass flags the user actually supplied so they override env/file but an unset flag doesn't.
|
|
225
|
+
cli: dict[str, dict[str, object]] = {}
|
|
226
|
+
if args.db is not None:
|
|
227
|
+
cli.setdefault("store", {})["path"] = args.db
|
|
228
|
+
if args.host is not None:
|
|
229
|
+
cli.setdefault("api", {})["host"] = args.host
|
|
230
|
+
if args.port is not None:
|
|
231
|
+
cli.setdefault("api", {})["port"] = args.port
|
|
232
|
+
if args.log_level is not None:
|
|
233
|
+
cli.setdefault("logging", {})["level"] = args.log_level
|
|
234
|
+
if args.env is not None:
|
|
235
|
+
cli.setdefault("ai", {})["environment"] = args.env # the single active-environment selector
|
|
236
|
+
if args.project_root is not None:
|
|
237
|
+
# Anchor for environments/<env>.toml resolution (overrides [environments].base_dir).
|
|
238
|
+
cli.setdefault("environments", {})["base_dir"] = args.project_root
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
settings = load_settings(config_path=args.service_config, cli=cli)
|
|
242
|
+
except (FileNotFoundError, ValueError, ValidationError) as exc:
|
|
243
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
244
|
+
return 2
|
|
245
|
+
|
|
246
|
+
# Fail closed: with auth disabled the API answers as a full-privilege system identity, so a
|
|
247
|
+
# non-loopback bind would publish admin access to the network. Loopback is the only no-auth posture.
|
|
248
|
+
if not settings.auth.enabled and not settings.api.is_loopback:
|
|
249
|
+
print(
|
|
250
|
+
"error: refusing to serve with [auth] enabled=false on non-loopback host "
|
|
251
|
+
f"{settings.api.host!r}; enable auth or bind 127.0.0.1",
|
|
252
|
+
file=sys.stderr,
|
|
253
|
+
)
|
|
254
|
+
return 2
|
|
255
|
+
|
|
256
|
+
if settings.store.backend is StoreBackend.SQLSERVER:
|
|
257
|
+
import importlib.util
|
|
258
|
+
|
|
259
|
+
if importlib.util.find_spec("aioodbc") is None:
|
|
260
|
+
print(
|
|
261
|
+
"error: the SQL Server backend needs the 'sqlserver' extra: "
|
|
262
|
+
"pip install 'messagefoundry[sqlserver]' (plus the Microsoft ODBC Driver 18)",
|
|
263
|
+
file=sys.stderr,
|
|
264
|
+
)
|
|
265
|
+
return 2
|
|
266
|
+
|
|
267
|
+
# Active environment is REQUIRED (ADR 0017): no silent default, so a missing env can never resolve
|
|
268
|
+
# another environment's values/secrets. Its security POSTURE (data_class / production) is derived
|
|
269
|
+
# for the built-in names dev/staging/prod and must be explicit for a custom name.
|
|
270
|
+
from messagefoundry.config.ai_policy import DataClass
|
|
271
|
+
|
|
272
|
+
if settings.ai.environment is None:
|
|
273
|
+
print(
|
|
274
|
+
"error: no active environment set — pass --env <name> or set [ai].environment. It selects "
|
|
275
|
+
"environments/<name>.toml and, with [ai].data_class/[ai].production, the instance's PHI "
|
|
276
|
+
"posture.",
|
|
277
|
+
file=sys.stderr,
|
|
278
|
+
)
|
|
279
|
+
return 2
|
|
280
|
+
try:
|
|
281
|
+
data_class, production = settings.ai.require_posture()
|
|
282
|
+
except ValueError as exc:
|
|
283
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
284
|
+
return 2
|
|
285
|
+
env_name = settings.ai.environment
|
|
286
|
+
|
|
287
|
+
# PHI-at-rest posture (WP-5/WP-11d): refuse (require_encryption) or warn (PHI-carrying instance)
|
|
288
|
+
# when no key is configured. A DPAPI-protected key file (Windows) counts as a configured key; if
|
|
289
|
+
# it's set but unreadable here, open_store fails closed at startup with the DPAPI error.
|
|
290
|
+
if not (settings.store.encryption_key or settings.store.encryption_key_file):
|
|
291
|
+
if settings.store.require_encryption:
|
|
292
|
+
print(
|
|
293
|
+
"error: [store].require_encryption is set but no MEFOR_STORE_ENCRYPTION_KEY (or "
|
|
294
|
+
"[store].encryption_key_file) is configured; refusing to start (PHI would be stored "
|
|
295
|
+
"unencrypted at rest)",
|
|
296
|
+
file=sys.stderr,
|
|
297
|
+
)
|
|
298
|
+
return 2
|
|
299
|
+
# Fail closed on a PRODUCTION PHI instance: a live production store must never run keyless
|
|
300
|
+
# (the prod analogue of require_encryption — the deployment doesn't have to set the flag to
|
|
301
|
+
# get the protection). staging/dev keep the softer posture below.
|
|
302
|
+
if production and data_class is DataClass.PHI:
|
|
303
|
+
print(
|
|
304
|
+
f"error: no MEFOR_STORE_ENCRYPTION_KEY (or [store].encryption_key_file) set on a "
|
|
305
|
+
f"production PHI instance ({env_name!r}); refusing to start — PHI bodies and the "
|
|
306
|
+
"error/last_error/detail columns would be stored UNENCRYPTED at rest. Generate a key "
|
|
307
|
+
"with `messagefoundry gen-key` (or protect one to a file with `messagefoundry "
|
|
308
|
+
"protect-key`) and configure it before starting a production store.",
|
|
309
|
+
file=sys.stderr,
|
|
310
|
+
)
|
|
311
|
+
return 2
|
|
312
|
+
# Warn on a non-production PHI-carrying instance (e.g. staging). A synthetic instance stays
|
|
313
|
+
# quiet to avoid alarm fatigue (CLAUDE.md §9 / docs/PHI.md).
|
|
314
|
+
if data_class is DataClass.PHI:
|
|
315
|
+
print(
|
|
316
|
+
f"warning: no MEFOR_STORE_ENCRYPTION_KEY set in a PHI-carrying environment "
|
|
317
|
+
f"({env_name!r}) — PHI bodies and the error/last_error/detail columns are stored "
|
|
318
|
+
"UNENCRYPTED at rest (only volume encryption protects them). Generate a key with "
|
|
319
|
+
"`messagefoundry gen-key` (or protect one to a file with `messagefoundry "
|
|
320
|
+
"protect-key`), or set [store].require_encryption.",
|
|
321
|
+
file=sys.stderr,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Open-egress posture (Q5b): on a PHI-carrying instance, outbound egress that is fully
|
|
325
|
+
# unrestricted — no [egress] allowlist AND deny_by_default off — lets a transform send PHI to any
|
|
326
|
+
# destination. On a PRODUCTION instance this fails closed (refuse to start, the prod analogue of
|
|
327
|
+
# the keyless-store refusal above); on a non-production PHI instance (e.g. staging) it is an
|
|
328
|
+
# advisory warning. A synthetic instance stays quiet. Lock it down with [egress].deny_by_default
|
|
329
|
+
# or per-transport [egress].allowed_* lists.
|
|
330
|
+
if data_class is DataClass.PHI:
|
|
331
|
+
eg = settings.egress
|
|
332
|
+
egress_open = not eg.deny_by_default and not (
|
|
333
|
+
eg.allowed_mllp
|
|
334
|
+
or eg.allowed_tcp
|
|
335
|
+
or eg.allowed_http
|
|
336
|
+
or eg.allowed_db
|
|
337
|
+
or eg.allowed_remote
|
|
338
|
+
or eg.allowed_file_dirs
|
|
339
|
+
)
|
|
340
|
+
if egress_open:
|
|
341
|
+
if production:
|
|
342
|
+
print(
|
|
343
|
+
f"error: outbound egress is UNRESTRICTED on a production PHI instance "
|
|
344
|
+
f"({env_name!r}); refusing to start — a transform could send PHI to any "
|
|
345
|
+
"destination. Set [egress].deny_by_default=true, or declare the permitted "
|
|
346
|
+
"destinations with per-transport [egress].allowed_* allowlists.",
|
|
347
|
+
file=sys.stderr,
|
|
348
|
+
)
|
|
349
|
+
return 2
|
|
350
|
+
print(
|
|
351
|
+
f"warning: outbound egress is UNRESTRICTED in a PHI-carrying environment "
|
|
352
|
+
f"({env_name!r}) — a transform may send to any destination. Set "
|
|
353
|
+
"[egress].deny_by_default or per-transport [egress].allowed_* allowlists to fail "
|
|
354
|
+
"closed.",
|
|
355
|
+
file=sys.stderr,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Gate #1: DEBUG logging can surface PHI (full message bodies / raw field values) into the general
|
|
359
|
+
# log. Refuse it fail-closed on a production instance — real PHI flows there. A non-production
|
|
360
|
+
# instance may use DEBUG for diagnostics.
|
|
361
|
+
if production and settings.logging.level.upper() == "DEBUG":
|
|
362
|
+
print(
|
|
363
|
+
"error: DEBUG logging is refused on a production instance ([ai].production=true) — it can "
|
|
364
|
+
"surface PHI (full message bodies / raw field values) into logs. Use INFO or higher in "
|
|
365
|
+
"production (set [ai].production=false on a non-production instance for verbose "
|
|
366
|
+
"diagnostics).",
|
|
367
|
+
file=sys.stderr,
|
|
368
|
+
)
|
|
369
|
+
return 2
|
|
370
|
+
|
|
371
|
+
# Off-box log forwarding (sec-offbox-log): ship a copy of every record to a syslog/SIEM collector
|
|
372
|
+
# so evidence survives a host compromise. PHI redaction + control-char scrubbing apply to the
|
|
373
|
+
# forwarded stream exactly as to stdout (configure_logging installs the same filters on both).
|
|
374
|
+
log_forward = (
|
|
375
|
+
SyslogForward(
|
|
376
|
+
host=settings.logging.forward_host,
|
|
377
|
+
port=settings.logging.forward_port,
|
|
378
|
+
protocol=settings.logging.forward_protocol.value,
|
|
379
|
+
fmt=settings.logging.forward_format.value,
|
|
380
|
+
)
|
|
381
|
+
if settings.logging.forward_enabled and settings.logging.forward_host
|
|
382
|
+
else None
|
|
383
|
+
)
|
|
384
|
+
forwarder_live = configure_logging(
|
|
385
|
+
settings.logging.level, fmt=settings.logging.format.value, forward=log_forward
|
|
386
|
+
)
|
|
387
|
+
if forwarder_live and log_forward is not None:
|
|
388
|
+
# Only announce forwarding when configure_logging actually installed the handler — a TCP
|
|
389
|
+
# collector that is down at startup is skipped (it warns), so this must not contradict it.
|
|
390
|
+
logging.getLogger(__name__).info(
|
|
391
|
+
"off-box log forwarding enabled → %s:%d (%s, %s)",
|
|
392
|
+
log_forward.host,
|
|
393
|
+
log_forward.port,
|
|
394
|
+
log_forward.protocol,
|
|
395
|
+
log_forward.fmt,
|
|
396
|
+
)
|
|
397
|
+
# Anchor for the per-environment value dir: [environments].base_dir (or --project-root) when set,
|
|
398
|
+
# else the working directory (unchanged default). Resolved once here so the startup log shows the
|
|
399
|
+
# exact file env() values come from — the standalone-repo / NSSM footgun is a silently-wrong path.
|
|
400
|
+
from pathlib import Path
|
|
401
|
+
|
|
402
|
+
from messagefoundry.config.environments import resolve_values_base_dir
|
|
403
|
+
|
|
404
|
+
env_base = resolve_values_base_dir(settings.environments.base_dir, cwd=Path.cwd())
|
|
405
|
+
# Announce the active environment + posture so an operator can see which env() values resolve and
|
|
406
|
+
# the PHI posture in effect (the env is required — there is no silent default).
|
|
407
|
+
logging.getLogger(__name__).info(
|
|
408
|
+
"active environment: %s (data_class=%s, production=%s; env() values from %s + MEFOR_VALUE_*)",
|
|
409
|
+
env_name,
|
|
410
|
+
data_class.value,
|
|
411
|
+
production,
|
|
412
|
+
env_base / settings.environments.dir / f"{env_name}.toml",
|
|
413
|
+
)
|
|
414
|
+
# A non-loopback API bind puts bearer tokens + PHI on the wire. The exposed-gate (ADR 0002 §0):
|
|
415
|
+
# TLS configured → the first-class secure path (allow); no TLS but --allow-insecure-bind → a loud
|
|
416
|
+
# dev override (warn); otherwise → refuse fail-closed. The auth-disabled case is refused above
|
|
417
|
+
# regardless of this flag — serving full-privilege admin to the network is never one "I accept the
|
|
418
|
+
# risk" away.
|
|
419
|
+
if not settings.api.is_loopback:
|
|
420
|
+
if settings.api.tls_enabled:
|
|
421
|
+
# WP-13a: TLS terminates in-process, so tokens + PHI are encrypted on the wire and HSTS
|
|
422
|
+
# engages — no dev escape needed.
|
|
423
|
+
logging.getLogger(__name__).info(
|
|
424
|
+
"API on non-loopback host %r with in-process TLS (https/wss).", settings.api.host
|
|
425
|
+
)
|
|
426
|
+
elif settings.api.tls_terminated_upstream:
|
|
427
|
+
# WP-15: a reverse proxy terminates TLS in front; trust forwarded headers only from the
|
|
428
|
+
# declared proxies (the validator guarantees trusted_proxies is set here).
|
|
429
|
+
logging.getLogger(__name__).info(
|
|
430
|
+
"API on non-loopback host %r behind a TLS-terminating proxy; trusting forwarded "
|
|
431
|
+
"headers from %s.",
|
|
432
|
+
settings.api.host,
|
|
433
|
+
settings.api.trusted_proxies,
|
|
434
|
+
)
|
|
435
|
+
elif args.allow_insecure_bind:
|
|
436
|
+
print(
|
|
437
|
+
f"warning: API bound to non-loopback host {settings.api.host!r} with "
|
|
438
|
+
"--allow-insecure-bind and NO TLS; bearer tokens and PHI cross the network in "
|
|
439
|
+
"cleartext — configure [api].tls_cert_file (+ tls_key_file) for real remote access.",
|
|
440
|
+
file=sys.stderr,
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
print(
|
|
444
|
+
"error: refusing to serve the API on non-loopback host "
|
|
445
|
+
f"{settings.api.host!r} without TLS; bearer tokens and PHI would cross the network in "
|
|
446
|
+
"cleartext. Configure [api].tls_cert_file for in-process TLS, set "
|
|
447
|
+
"[api].tls_terminated_upstream (+ trusted_proxies) if a proxy terminates TLS, or pass "
|
|
448
|
+
"--allow-insecure-bind to accept the cleartext risk on a trusted, firewalled network.",
|
|
449
|
+
file=sys.stderr,
|
|
450
|
+
)
|
|
451
|
+
return 2
|
|
452
|
+
|
|
453
|
+
# MFA-at-exposure posture (sec-mfa-on; WP-14, ASVS 6.3.3): an off-loopback bind serving local
|
|
454
|
+
# accounts puts admin authentication on the network, where a single password factor is far weaker.
|
|
455
|
+
# [auth].require_mfa adds the native TOTP second factor for the Administrator role; with it off the
|
|
456
|
+
# admin interface is single-factor over the wire. Mirror the keyless-store / open-egress posture:
|
|
457
|
+
# refuse on a production PHI instance (the prod fail-closed analogue), warn on a non-production PHI
|
|
458
|
+
# instance, stay quiet on a synthetic instance. Reached only for an otherwise-permitted exposed
|
|
459
|
+
# bind (the TLS gate above ran first); the loopback default never trips it. AD/Kerberos MFA is
|
|
460
|
+
# delegated to the directory, so require_mfa only gates LOCAL Administrator accounts (the bootstrap
|
|
461
|
+
# admin is one) — it is safe to enable even on an AD-only deployment.
|
|
462
|
+
if not settings.api.is_loopback and settings.auth.enabled and not settings.auth.require_mfa:
|
|
463
|
+
if data_class is DataClass.PHI:
|
|
464
|
+
if production:
|
|
465
|
+
print(
|
|
466
|
+
f"error: API bound to non-loopback host {settings.api.host!r} on a production PHI "
|
|
467
|
+
f"instance ({env_name!r}) with [auth].require_mfa off; refusing to start — the "
|
|
468
|
+
"Administrator role would authenticate with a single factor over the network. "
|
|
469
|
+
"Enable native TOTP MFA with [auth].require_mfa=true (WP-14) before exposing the "
|
|
470
|
+
"API (safe even on an AD-only deployment — it gates only local Administrator "
|
|
471
|
+
"accounts).",
|
|
472
|
+
file=sys.stderr,
|
|
473
|
+
)
|
|
474
|
+
return 2
|
|
475
|
+
print(
|
|
476
|
+
f"warning: API bound to non-loopback host {settings.api.host!r} in a PHI-carrying "
|
|
477
|
+
f"environment ({env_name!r}) with [auth].require_mfa off — the Administrator role is "
|
|
478
|
+
"single-factor over the network. Enable [auth].require_mfa=true (WP-14 native TOTP) "
|
|
479
|
+
"before exposure.",
|
|
480
|
+
file=sys.stderr,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# This instance's environment values (env() lookups in the graph): environments/<env>.toml +
|
|
484
|
+
# MEFOR_VALUE_* env, anchored at env_base (above). The active environment is the single selector
|
|
485
|
+
# [ai].environment. Passed as a provider (re-read on each reload, not just startup) so a promote
|
|
486
|
+
# picks up edited values without a service restart (review M-23) — the anchor is fixed per process.
|
|
487
|
+
import os
|
|
488
|
+
|
|
489
|
+
from messagefoundry.config.environments import load_environment_values
|
|
490
|
+
|
|
491
|
+
def env_values() -> dict[str, Any]:
|
|
492
|
+
return load_environment_values(
|
|
493
|
+
base_dir=env_base,
|
|
494
|
+
dir_name=settings.environments.dir,
|
|
495
|
+
environment=env_name,
|
|
496
|
+
environ=os.environ,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
app = create_managed_app(
|
|
500
|
+
store_settings=settings.store,
|
|
501
|
+
config_dir=args.config,
|
|
502
|
+
config_reload_roots=settings.api.config_reload_roots,
|
|
503
|
+
inbound_bind_host=settings.inbound.bind_host,
|
|
504
|
+
allow_insecure_bind=args.allow_insecure_bind,
|
|
505
|
+
delivery_defaults=settings.delivery.retry_policy(),
|
|
506
|
+
ordering_default=settings.delivery.ordering,
|
|
507
|
+
internal_error_default=settings.delivery.internal_error,
|
|
508
|
+
buildup_default=settings.delivery.buildup_threshold(),
|
|
509
|
+
ack_after_default=settings.inbound.ack_after,
|
|
510
|
+
max_correlation_depth=settings.pipeline.max_correlation_depth,
|
|
511
|
+
env_values_provider=env_values,
|
|
512
|
+
auth_settings=settings.auth,
|
|
513
|
+
ai_settings=settings.ai,
|
|
514
|
+
alerts_settings=settings.alerts,
|
|
515
|
+
retention_settings=settings.retention,
|
|
516
|
+
cert_monitor_settings=settings.cert_monitor,
|
|
517
|
+
api_tls_cert_file=settings.api.tls_cert_file,
|
|
518
|
+
reference_settings=settings.reference,
|
|
519
|
+
egress_settings=settings.egress,
|
|
520
|
+
shadow_settings=settings.shadow,
|
|
521
|
+
cluster_settings=settings.cluster,
|
|
522
|
+
approvals_settings=settings.approvals,
|
|
523
|
+
expose_docs=settings.api.expose_docs,
|
|
524
|
+
ws_allowed_origins=settings.api.ws_allowed_origins,
|
|
525
|
+
)
|
|
526
|
+
# log_config=None: uvicorn's loggers propagate to the handler configure_logging installed,
|
|
527
|
+
# so everything shares one format/stream (and one log file under NSSM).
|
|
528
|
+
# WP-15: trust X-Forwarded-For/-Proto ONLY from the declared reverse proxies, so the audit /
|
|
529
|
+
# rate-limit source IP is the real client (not the proxy). Empty list = trust nothing (the secure
|
|
530
|
+
# default — the direct TCP peer is used), overriding uvicorn's loopback default.
|
|
531
|
+
run_kwargs: dict[str, Any] = {
|
|
532
|
+
"log_config": None,
|
|
533
|
+
"forwarded_allow_ips": settings.api.trusted_proxies,
|
|
534
|
+
# WP-L3-07 (ASVS 13.4.6): drop the `Server: uvicorn` banner so a response doesn't advertise the
|
|
535
|
+
# server implementation/version to an unauthenticated caller.
|
|
536
|
+
"server_header": False,
|
|
537
|
+
}
|
|
538
|
+
if settings.api.tls_enabled:
|
|
539
|
+
# WP-13a: terminate TLS in-process. Build the context now so a bad cert/key/passphrase fails
|
|
540
|
+
# fast (before uvicorn opens the socket); pass it via uvicorn's ssl_context_factory so the
|
|
541
|
+
# tls_min_version floor is enforced exactly.
|
|
542
|
+
from messagefoundry.api.tls import build_api_ssl_context
|
|
543
|
+
|
|
544
|
+
ctx = build_api_ssl_context(settings.api)
|
|
545
|
+
run_kwargs["ssl_context_factory"] = lambda config, default_factory: ctx
|
|
546
|
+
from messagefoundry.last_resort import install_excepthook
|
|
547
|
+
from messagefoundry.redaction import safe_exc
|
|
548
|
+
|
|
549
|
+
install_excepthook() # last-resort main-thread hook: an uncaught exception logs PHI-redacted (16.5.4)
|
|
550
|
+
try:
|
|
551
|
+
uvicorn.run(app, host=settings.api.host, port=settings.api.port, **run_kwargs)
|
|
552
|
+
except Exception as exc: # last-resort: log an abnormal server exit PHI-redacted, then re-raise
|
|
553
|
+
logging.getLogger(__name__).critical("server exited abnormally: %s", safe_exc(exc))
|
|
554
|
+
raise
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
559
|
+
from messagefoundry.config.wiring import validate_config
|
|
560
|
+
|
|
561
|
+
diags = validate_config(args.config)
|
|
562
|
+
if args.json:
|
|
563
|
+
print(
|
|
564
|
+
json.dumps(
|
|
565
|
+
[{"message": d.message, "file": d.file, "severity": d.severity} for d in diags]
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
elif not diags:
|
|
569
|
+
print("OK: no problems found")
|
|
570
|
+
else:
|
|
571
|
+
for d in diags:
|
|
572
|
+
print(f"{d.severity}: {d.file or '-'}: {d.message}")
|
|
573
|
+
return 1 if diags else 0
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _graph(args: argparse.Namespace) -> int:
|
|
577
|
+
from messagefoundry.config.wiring import WiringError, display_settings, load_config
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
reg = load_config(args.config)
|
|
581
|
+
except WiringError as exc:
|
|
582
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
583
|
+
data = {
|
|
584
|
+
"inbound": [
|
|
585
|
+
{
|
|
586
|
+
"name": name,
|
|
587
|
+
"type": c.spec.type.value,
|
|
588
|
+
"settings": display_settings(c.spec.settings),
|
|
589
|
+
"router": c.router,
|
|
590
|
+
"ack_mode": c.ack_mode.value,
|
|
591
|
+
"strict": c.validation.strict,
|
|
592
|
+
"file": c.source_file,
|
|
593
|
+
"line": c.source_line,
|
|
594
|
+
}
|
|
595
|
+
for name, c in reg.inbound.items()
|
|
596
|
+
],
|
|
597
|
+
"outbound": [
|
|
598
|
+
{
|
|
599
|
+
"name": name,
|
|
600
|
+
"type": c.spec.type.value,
|
|
601
|
+
"settings": display_settings(c.spec.settings),
|
|
602
|
+
"file": c.source_file,
|
|
603
|
+
"line": c.source_line,
|
|
604
|
+
}
|
|
605
|
+
for name, c in reg.outbound.items()
|
|
606
|
+
],
|
|
607
|
+
# router→handler and handler→outbound edges are decided in code, not declared, so they're
|
|
608
|
+
# extracted best-effort: a handler/outbound name that appears as a string literal in the
|
|
609
|
+
# function counts as a reference. Accurate for names written literally; misses computed names.
|
|
610
|
+
"routers": [
|
|
611
|
+
{"name": n, **_fn_location(fn), "handlers": _referenced(fn, reg.handlers)}
|
|
612
|
+
for n, fn in sorted(reg.routers.items())
|
|
613
|
+
],
|
|
614
|
+
"handlers": [
|
|
615
|
+
{"name": n, **_fn_location(fn), "sends": _referenced(fn, reg.outbound)}
|
|
616
|
+
for n, fn in sorted(reg.handlers.items())
|
|
617
|
+
],
|
|
618
|
+
}
|
|
619
|
+
_print_json(data, compact=args.json)
|
|
620
|
+
return 0
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _fn_location(fn: object) -> dict[str, Any]:
|
|
624
|
+
"""File + line where a Router/Handler function is defined (for IDE go-to-definition)."""
|
|
625
|
+
code = getattr(fn, "__code__", None)
|
|
626
|
+
if code is None:
|
|
627
|
+
return {"file": None, "line": None}
|
|
628
|
+
return {"file": code.co_filename, "line": code.co_firstlineno}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _referenced(fn: object, names: dict[str, Any]) -> list[str]:
|
|
632
|
+
"""Best-effort: which of ``names`` appear as string literals in ``fn`` (router/handler wiring)."""
|
|
633
|
+
consts = _string_consts(fn)
|
|
634
|
+
return sorted(name for name in names if name in consts)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _string_consts(fn: object) -> set[str]:
|
|
638
|
+
"""All string constants in a function, recursing into nested code objects (comprehensions, etc.)."""
|
|
639
|
+
import types
|
|
640
|
+
|
|
641
|
+
code = getattr(fn, "__code__", None)
|
|
642
|
+
if code is None:
|
|
643
|
+
return set()
|
|
644
|
+
found: set[str] = set()
|
|
645
|
+
stack = [code]
|
|
646
|
+
while stack:
|
|
647
|
+
current = stack.pop()
|
|
648
|
+
for const in current.co_consts:
|
|
649
|
+
if isinstance(const, str):
|
|
650
|
+
found.add(const)
|
|
651
|
+
elif isinstance(const, types.CodeType):
|
|
652
|
+
stack.append(const)
|
|
653
|
+
return found
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _redact_body(body: str) -> str:
|
|
657
|
+
"""Replace a PHI-bearing message body with a length placeholder.
|
|
658
|
+
|
|
659
|
+
``dryrun`` is a dev tool whose output is routinely piped to files/CI logs, so it must not emit
|
|
660
|
+
full bodies (raw + would-send payloads) by default; ``--show-phi`` opts in. See docs/PHI.md §7.
|
|
661
|
+
"""
|
|
662
|
+
return f"<redacted {len(body)} chars; pass --show-phi>" if body else body
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _dryrun(args: argparse.Namespace) -> int:
|
|
666
|
+
from messagefoundry.config.wiring import WiringError, load_config
|
|
667
|
+
from messagefoundry.pipeline.dryrun import dry_run, read_messages
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
reg = load_config(args.config)
|
|
671
|
+
except WiringError as exc:
|
|
672
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
673
|
+
try:
|
|
674
|
+
messages = read_messages(args.messages)
|
|
675
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
676
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
677
|
+
|
|
678
|
+
show_phi: bool = args.show_phi
|
|
679
|
+
if not show_phi:
|
|
680
|
+
print(
|
|
681
|
+
"note: message bodies redacted; pass --show-phi to include raw/payloads (PHI)",
|
|
682
|
+
file=sys.stderr,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
out: list[dict[str, Any]] = []
|
|
686
|
+
try:
|
|
687
|
+
for source, path, raw in messages:
|
|
688
|
+
result = dry_run(reg, raw, inbound=args.inbound)
|
|
689
|
+
out.append(
|
|
690
|
+
{
|
|
691
|
+
"source": source,
|
|
692
|
+
"path": path,
|
|
693
|
+
"inbound": result.inbound,
|
|
694
|
+
"disposition": result.disposition.value,
|
|
695
|
+
"message_type": result.message_type,
|
|
696
|
+
"control_id": result.control_id,
|
|
697
|
+
# The summary is PHI (MRN + patient name from PID-3/5), so gate it like raw/
|
|
698
|
+
# payloads — dryrun stdout is routinely piped to files/CI logs (review H-12).
|
|
699
|
+
# (The `error` text can also quote field values; that's tracked separately as
|
|
700
|
+
# low-8, gated holistically with the API's error exposure.)
|
|
701
|
+
"summary": result.summary if show_phi else None,
|
|
702
|
+
"handlers": result.handlers,
|
|
703
|
+
"deliveries": [
|
|
704
|
+
{"to": d.to, "payload": d.payload if show_phi else _redact_body(d.payload)}
|
|
705
|
+
for d in result.deliveries
|
|
706
|
+
],
|
|
707
|
+
# Declared state writes (ADR 0005). The value can be PHI (e.g. an MRN→anon
|
|
708
|
+
# mapping), so gate it behind --show-phi exactly like a delivery payload.
|
|
709
|
+
"state_ops": [
|
|
710
|
+
{
|
|
711
|
+
"namespace": s.namespace,
|
|
712
|
+
"key": s.key if show_phi else _redact_body(str(s.key)),
|
|
713
|
+
"value": s.value if show_phi else _redact_body(str(s.value)),
|
|
714
|
+
}
|
|
715
|
+
for s in result.state_ops
|
|
716
|
+
],
|
|
717
|
+
"error": result.error,
|
|
718
|
+
"raw": result.raw if show_phi else _redact_body(result.raw),
|
|
719
|
+
}
|
|
720
|
+
)
|
|
721
|
+
except (ValueError, KeyError) as exc: # e.g. ambiguous/unknown --inbound
|
|
722
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
723
|
+
_print_json(out, compact=args.json)
|
|
724
|
+
return 0
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _hl7schema(args: argparse.Namespace) -> int:
|
|
728
|
+
from messagefoundry.hl7schema import hl7_schema
|
|
729
|
+
|
|
730
|
+
_print_json(hl7_schema(), compact=args.json)
|
|
731
|
+
return 0
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _init(args: argparse.Namespace) -> int:
|
|
735
|
+
"""Scaffold a new config repo into ``args.dir`` (starter feed + environments + CI + a pinned engine)."""
|
|
736
|
+
from pathlib import Path
|
|
737
|
+
|
|
738
|
+
from messagefoundry.scaffold import scaffold
|
|
739
|
+
|
|
740
|
+
target = Path(args.dir)
|
|
741
|
+
try:
|
|
742
|
+
written = scaffold(target, force=args.force)
|
|
743
|
+
except (FileExistsError, NotADirectoryError, OSError) as exc:
|
|
744
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
745
|
+
|
|
746
|
+
rels = [str(p.relative_to(target)) for p in written]
|
|
747
|
+
if args.json:
|
|
748
|
+
_print_json({"target": str(target), "written": rels}, compact=True)
|
|
749
|
+
return 0
|
|
750
|
+
if not written:
|
|
751
|
+
print(f"Nothing written — {target} already has every scaffold file.")
|
|
752
|
+
return 0
|
|
753
|
+
print(f"Scaffolded a config repo in {target} ({len(written)} files):")
|
|
754
|
+
for rel in rels:
|
|
755
|
+
print(f" {rel}")
|
|
756
|
+
print("\nNext steps:")
|
|
757
|
+
print(" pip install -r requirements.txt # the pinned engine (a read-only dependency)")
|
|
758
|
+
print(" messagefoundry check --config config --messages messages/sets")
|
|
759
|
+
print(" messagefoundry serve --config config --env dev")
|
|
760
|
+
return 0
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _gen_key(_args: argparse.Namespace) -> int:
|
|
764
|
+
from messagefoundry.store.crypto import generate_key
|
|
765
|
+
|
|
766
|
+
# Print only the key (so it can be piped); set it as MEFOR_STORE_ENCRYPTION_KEY, never the file.
|
|
767
|
+
print(generate_key())
|
|
768
|
+
return 0
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _protect_key(args: argparse.Namespace) -> int:
|
|
772
|
+
"""DPAPI-protect the store encryption key to a file (WP-11d, ASVS 13.3.1; Windows-only).
|
|
773
|
+
|
|
774
|
+
Source: ``--generate`` mints a fresh key (also printed once to stderr so it can be backed up
|
|
775
|
+
offline — the machine-bound file is unrecoverable if the host is lost); otherwise the key is read
|
|
776
|
+
from ``MEFOR_STORE_ENCRYPTION_KEY``. The file is written with an owner-only DACL on top of DPAPI.
|
|
777
|
+
"""
|
|
778
|
+
import base64
|
|
779
|
+
import os
|
|
780
|
+
from pathlib import Path
|
|
781
|
+
|
|
782
|
+
from messagefoundry.secrets_dpapi import DpapiError, DpapiUnavailable, protect_key_to_file
|
|
783
|
+
from messagefoundry.store.crypto import generate_key
|
|
784
|
+
from messagefoundry.store.store import _secure_file
|
|
785
|
+
|
|
786
|
+
if args.generate:
|
|
787
|
+
key_b64 = generate_key()
|
|
788
|
+
print(
|
|
789
|
+
"Generated a new store key. BACK IT UP OFFLINE — the protected file is bound to this "
|
|
790
|
+
f"machine and cannot be recovered if the host is lost:\n {key_b64}",
|
|
791
|
+
file=sys.stderr,
|
|
792
|
+
)
|
|
793
|
+
else:
|
|
794
|
+
key_b64 = os.environ.get("MEFOR_STORE_ENCRYPTION_KEY", "").strip()
|
|
795
|
+
if not key_b64:
|
|
796
|
+
print(
|
|
797
|
+
"error: no key to protect — set MEFOR_STORE_ENCRYPTION_KEY, or pass --generate to "
|
|
798
|
+
"mint a fresh one",
|
|
799
|
+
file=sys.stderr,
|
|
800
|
+
)
|
|
801
|
+
return 2
|
|
802
|
+
|
|
803
|
+
try:
|
|
804
|
+
raw = base64.b64decode(key_b64, validate=True)
|
|
805
|
+
except (ValueError, base64.binascii.Error): # type: ignore[attr-defined]
|
|
806
|
+
raw = b""
|
|
807
|
+
if len(raw) != 32:
|
|
808
|
+
print(
|
|
809
|
+
"error: the key must be base64 of 32 bytes (use `gen-key` or --generate)",
|
|
810
|
+
file=sys.stderr,
|
|
811
|
+
)
|
|
812
|
+
return 2
|
|
813
|
+
|
|
814
|
+
out = Path(args.out)
|
|
815
|
+
try:
|
|
816
|
+
protect_key_to_file(key_b64, out, machine_scope=not args.user)
|
|
817
|
+
except DpapiUnavailable as exc:
|
|
818
|
+
print(
|
|
819
|
+
f"error: {exc}. protect-key is Windows-only; on other platforms keep the key in "
|
|
820
|
+
"MEFOR_STORE_ENCRYPTION_KEY.",
|
|
821
|
+
file=sys.stderr,
|
|
822
|
+
)
|
|
823
|
+
return 2
|
|
824
|
+
except DpapiError as exc:
|
|
825
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
826
|
+
return 2
|
|
827
|
+
_secure_file(out) # owner-only DACL — defence in depth atop the DPAPI binding
|
|
828
|
+
print(
|
|
829
|
+
f"Wrote DPAPI-protected key to {out}.\nNext: set [store].encryption_key_file = {str(out)!r} "
|
|
830
|
+
"and unset MEFOR_STORE_ENCRYPTION_KEY."
|
|
831
|
+
)
|
|
832
|
+
return 0
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _audit_verify(args: argparse.Namespace) -> int:
|
|
836
|
+
import asyncio
|
|
837
|
+
from pathlib import Path
|
|
838
|
+
|
|
839
|
+
from pydantic import ValidationError
|
|
840
|
+
|
|
841
|
+
from messagefoundry.config.settings import StoreBackend, load_settings
|
|
842
|
+
from messagefoundry.store.base import open_store
|
|
843
|
+
|
|
844
|
+
cli: dict[str, dict[str, object]] = {}
|
|
845
|
+
if args.db is not None:
|
|
846
|
+
cli.setdefault("store", {})["path"] = args.db
|
|
847
|
+
try:
|
|
848
|
+
settings = load_settings(config_path=args.service_config, cli=cli)
|
|
849
|
+
except (FileNotFoundError, ValueError, ValidationError) as exc:
|
|
850
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
851
|
+
return 2
|
|
852
|
+
|
|
853
|
+
# A SQLite store would otherwise be CREATED on open: a compliance job pointed at a typo'd path
|
|
854
|
+
# would silently get a fresh empty DB and report "OK: verified 0 audit row(s)" forever (M-31).
|
|
855
|
+
if settings.store.backend == StoreBackend.SQLITE and not Path(settings.store.path).exists():
|
|
856
|
+
print(
|
|
857
|
+
f"error: no audit database at {settings.store.path} — refusing to create one and report "
|
|
858
|
+
f"a false 'verified 0 rows' (check --db / [store].path)",
|
|
859
|
+
file=sys.stderr,
|
|
860
|
+
)
|
|
861
|
+
return 2
|
|
862
|
+
|
|
863
|
+
async def run() -> tuple[bool, str | None]:
|
|
864
|
+
store = await open_store(settings.store)
|
|
865
|
+
try:
|
|
866
|
+
return await store.verify_audit_chain()
|
|
867
|
+
finally:
|
|
868
|
+
await store.close()
|
|
869
|
+
|
|
870
|
+
ok, message = asyncio.run(run())
|
|
871
|
+
print(("OK: " if ok else "FAIL: ") + (message or ""))
|
|
872
|
+
if ok and message and "verified 0 " in message:
|
|
873
|
+
# An empty log on a real DB is legitimate but worth flagging — it's indistinguishable at a
|
|
874
|
+
# glance from pointing at the wrong database (M-31).
|
|
875
|
+
print(
|
|
876
|
+
"warning: the audit log is empty — confirm this is the intended database.",
|
|
877
|
+
file=sys.stderr,
|
|
878
|
+
)
|
|
879
|
+
return 0 if ok else 1
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _rotate_key(args: argparse.Namespace) -> int:
|
|
883
|
+
"""Re-encrypt every cipher-covered value under the active key (WP-5 key rotation, ASVS 11.2.2).
|
|
884
|
+
|
|
885
|
+
Run **offline** (engine stopped): set ``MEFOR_STORE_ENCRYPTION_KEY`` to the NEW active key and keep
|
|
886
|
+
the prior key(s) in ``MEFOR_STORE_ENCRYPTION_KEYS_RETIRED`` so existing rows can be decrypted, then
|
|
887
|
+
rotate. After it finishes, the retired key can be removed.
|
|
888
|
+
"""
|
|
889
|
+
import asyncio
|
|
890
|
+
from pathlib import Path
|
|
891
|
+
|
|
892
|
+
from pydantic import ValidationError
|
|
893
|
+
|
|
894
|
+
from messagefoundry.config.settings import StoreBackend, load_settings
|
|
895
|
+
from messagefoundry.secrets_dpapi import DpapiError, DpapiUnavailable
|
|
896
|
+
from messagefoundry.store.base import open_store, resolve_active_key
|
|
897
|
+
from messagefoundry.store.crypto import CipherError
|
|
898
|
+
from messagefoundry.store.keyprovider import KeyProviderError
|
|
899
|
+
|
|
900
|
+
cli: dict[str, dict[str, object]] = {}
|
|
901
|
+
if args.db is not None:
|
|
902
|
+
cli.setdefault("store", {})["path"] = args.db
|
|
903
|
+
try:
|
|
904
|
+
settings = load_settings(config_path=args.service_config, cli=cli)
|
|
905
|
+
except (FileNotFoundError, ValueError, ValidationError) as exc:
|
|
906
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
907
|
+
return 2
|
|
908
|
+
|
|
909
|
+
try:
|
|
910
|
+
active_key = resolve_active_key(settings.store)
|
|
911
|
+
except (DpapiError, DpapiUnavailable, KeyProviderError) as exc:
|
|
912
|
+
# KeyProviderError: a non-default [store].key_provider that is unknown or not-yet-built (an
|
|
913
|
+
# external HSM/KMS/Vault provider) — fail closed with a clean exit-2, not a traceback (ADR 0019).
|
|
914
|
+
print(f"error: cannot load the active key for rotation: {exc}", file=sys.stderr)
|
|
915
|
+
return 2
|
|
916
|
+
if not active_key:
|
|
917
|
+
print(
|
|
918
|
+
"error: rotate-key needs an active key — set MEFOR_STORE_ENCRYPTION_KEY (or "
|
|
919
|
+
"[store].encryption_key_file) to the new active key, with any prior key in "
|
|
920
|
+
"MEFOR_STORE_ENCRYPTION_KEYS_RETIRED; none is configured",
|
|
921
|
+
file=sys.stderr,
|
|
922
|
+
)
|
|
923
|
+
return 2
|
|
924
|
+
if settings.store.backend == StoreBackend.SQLITE and not Path(settings.store.path).exists():
|
|
925
|
+
print(
|
|
926
|
+
f"error: no store at {settings.store.path} (check --db / [store].path)", file=sys.stderr
|
|
927
|
+
)
|
|
928
|
+
return 2
|
|
929
|
+
|
|
930
|
+
async def run() -> int:
|
|
931
|
+
store = await open_store(settings.store)
|
|
932
|
+
try:
|
|
933
|
+
return await store.reencrypt_to_active()
|
|
934
|
+
finally:
|
|
935
|
+
await store.close()
|
|
936
|
+
|
|
937
|
+
try:
|
|
938
|
+
count = asyncio.run(run())
|
|
939
|
+
except CipherError as exc:
|
|
940
|
+
# A value couldn't be decrypted by any supplied key — the prior key is missing. Nothing was
|
|
941
|
+
# corrupted (a batch is all-or-nothing); supply the key and re-run.
|
|
942
|
+
print(f"error: rotation aborted — {exc}", file=sys.stderr)
|
|
943
|
+
return 1
|
|
944
|
+
except NotImplementedError as exc:
|
|
945
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
946
|
+
return 2
|
|
947
|
+
print(f"OK: re-encrypted {count} value(s) under the active key")
|
|
948
|
+
return 0
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _ai_policy(args: argparse.Namespace) -> int:
|
|
952
|
+
"""Print the effective AI-assistance policy resolved from local service settings.
|
|
953
|
+
|
|
954
|
+
Offline mirror of ``GET /ai/policy`` for the IDE's fallback path: it reads the same [ai] config
|
|
955
|
+
and runs the same clamp, but ``assist_permitted`` is always ``null`` because RBAC can't be
|
|
956
|
+
evaluated without the engine. Prints config only — never message data (PHI-safe)."""
|
|
957
|
+
from pydantic import ValidationError
|
|
958
|
+
|
|
959
|
+
from messagefoundry.config.ai_policy import resolve_effective_policy
|
|
960
|
+
from messagefoundry.config.settings import load_settings
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
settings = load_settings(config_path=args.service_config)
|
|
964
|
+
except (FileNotFoundError, ValueError, ValidationError) as exc:
|
|
965
|
+
# Surface via stdout so the IDE's runJson bridge sees it (mirrors the wire-error shape).
|
|
966
|
+
print(json.dumps({"error": str(exc)}))
|
|
967
|
+
return 2
|
|
968
|
+
|
|
969
|
+
ai = settings.ai
|
|
970
|
+
data_class, prod = ai.derived_posture()
|
|
971
|
+
production = True if prod is None else prod # unresolved posture -> strictest ceiling
|
|
972
|
+
eff = resolve_effective_policy(mode=ai.mode, data_scope=ai.data_scope, production=production)
|
|
973
|
+
payload = {
|
|
974
|
+
"mode": eff.mode.value,
|
|
975
|
+
"data_scope": eff.data_scope.value,
|
|
976
|
+
"environment": ai.environment,
|
|
977
|
+
"data_class": data_class.value if data_class is not None else None,
|
|
978
|
+
"production": production,
|
|
979
|
+
"assist_permitted": None, # RBAC is not evaluable offline
|
|
980
|
+
"reason": eff.reason,
|
|
981
|
+
}
|
|
982
|
+
_print_json(payload, compact=args.json)
|
|
983
|
+
return 0
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _generate(args: argparse.Namespace) -> int:
|
|
987
|
+
from messagefoundry.generators import _core
|
|
988
|
+
from messagefoundry.generators import all_types # noqa: F401 (registers every built-in type)
|
|
989
|
+
|
|
990
|
+
if args.list:
|
|
991
|
+
listing = {code: _core.triggers_for(code) for code in _core.message_codes()}
|
|
992
|
+
if args.json:
|
|
993
|
+
_print_json(listing, compact=True)
|
|
994
|
+
else:
|
|
995
|
+
for code, trigs in listing.items():
|
|
996
|
+
print(f"{code}: {len(trigs)} trigger(s) ({', '.join(trigs)})")
|
|
997
|
+
return 0
|
|
998
|
+
|
|
999
|
+
if not args.type:
|
|
1000
|
+
print("error: --type is required (or use --list to see types)", file=sys.stderr)
|
|
1001
|
+
return 2
|
|
1002
|
+
|
|
1003
|
+
code = args.type.upper()
|
|
1004
|
+
triggers = [t.strip().upper() for t in args.triggers.split(",") if t.strip()] or None
|
|
1005
|
+
out = args.out or f"samples/messages/{code.lower()}"
|
|
1006
|
+
seed = args.seed or _core.DEFAULT_SEED
|
|
1007
|
+
try:
|
|
1008
|
+
result = _core.write_corpus(code, triggers=triggers, count=args.count, out=out, seed=seed)
|
|
1009
|
+
except KeyError as exc:
|
|
1010
|
+
print(f"error: {exc.args[0] if exc.args else exc}", file=sys.stderr)
|
|
1011
|
+
return 2
|
|
1012
|
+
except _core.GenerationError as exc:
|
|
1013
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1014
|
+
return 1
|
|
1015
|
+
|
|
1016
|
+
if args.json:
|
|
1017
|
+
_print_json(
|
|
1018
|
+
{
|
|
1019
|
+
"type": result.code,
|
|
1020
|
+
"out": result.out_dir,
|
|
1021
|
+
"total": result.total,
|
|
1022
|
+
"by_trigger": result.by_trigger,
|
|
1023
|
+
},
|
|
1024
|
+
compact=True,
|
|
1025
|
+
)
|
|
1026
|
+
else:
|
|
1027
|
+
for trig, n in result.by_trigger.items():
|
|
1028
|
+
print(f"{code}^{trig}: {n}")
|
|
1029
|
+
print(f"Generated {result.total} message(s) into {result.out_dir}/")
|
|
1030
|
+
return 0
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _check(args: argparse.Namespace) -> int:
|
|
1034
|
+
"""Commit/CI gate: exit 0 iff every *required* check passed (advisory failures only print)."""
|
|
1035
|
+
from messagefoundry.checks import run_checks
|
|
1036
|
+
|
|
1037
|
+
report = run_checks(args.config, messages_dir=args.messages, run_lint=not args.no_lint)
|
|
1038
|
+
if args.json:
|
|
1039
|
+
_print_json(report.to_json(), compact=True)
|
|
1040
|
+
else:
|
|
1041
|
+
for r in report.results:
|
|
1042
|
+
status = "skip" if r.skipped else ("ok" if r.ok else "FAIL")
|
|
1043
|
+
tag = "" if r.required else " (advisory)"
|
|
1044
|
+
line = f"{status:>4} {r.name}{tag}"
|
|
1045
|
+
print(f"{line}: {r.detail}" if r.detail else line)
|
|
1046
|
+
print("PASS" if report.ok else "FAIL: a required check failed")
|
|
1047
|
+
return 0 if report.ok else 1
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _connection(args: argparse.Namespace) -> int:
|
|
1051
|
+
"""Manage the data-authored ``connections.toml`` (ADR 0007): ``list`` to populate the VS Code
|
|
1052
|
+
editor, ``upsert``/``remove`` to save (a developer can also hand-edit the file). ``upsert``/
|
|
1053
|
+
``remove`` validate the whole config dir (structure + connector/egress build-check) BEFORE
|
|
1054
|
+
persisting and roll back on failure. Offline: touches no network, starts no server."""
|
|
1055
|
+
import os
|
|
1056
|
+
from pathlib import Path
|
|
1057
|
+
|
|
1058
|
+
from pydantic import ValidationError
|
|
1059
|
+
|
|
1060
|
+
from messagefoundry.config import connections_edit
|
|
1061
|
+
from messagefoundry.config.environments import (
|
|
1062
|
+
load_environment_values,
|
|
1063
|
+
resolve_values_base_dir,
|
|
1064
|
+
)
|
|
1065
|
+
from messagefoundry.config.settings import load_settings
|
|
1066
|
+
from messagefoundry.config.wiring import WiringError, load_config
|
|
1067
|
+
from messagefoundry.pipeline.wiring_runner import build_check_registry
|
|
1068
|
+
|
|
1069
|
+
if args.action == "list":
|
|
1070
|
+
try:
|
|
1071
|
+
entries = connections_edit.list_connections(args.config)
|
|
1072
|
+
except (OSError, WiringError) as exc:
|
|
1073
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
1074
|
+
_print_json(entries, compact=args.json)
|
|
1075
|
+
return 0
|
|
1076
|
+
|
|
1077
|
+
# upsert / remove: validate the candidate dir against this instance's [egress] allowlist + active
|
|
1078
|
+
# environment before persisting, so a GUI edit pointing at a non-allowlisted host fails at edit
|
|
1079
|
+
# time exactly as it would at reload.
|
|
1080
|
+
try:
|
|
1081
|
+
settings = load_settings(config_path=args.service_config)
|
|
1082
|
+
except (FileNotFoundError, ValueError, ValidationError) as exc:
|
|
1083
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
1084
|
+
env_name = settings.ai.environment
|
|
1085
|
+
# Anchor environments/<env>.toml the same way serve does (honor [environments].base_dir), so a
|
|
1086
|
+
# GUI/CLI edit validates against the same env() values the running instance will resolve.
|
|
1087
|
+
env_values = (
|
|
1088
|
+
load_environment_values(
|
|
1089
|
+
base_dir=resolve_values_base_dir(settings.environments.base_dir, cwd=Path.cwd()),
|
|
1090
|
+
dir_name=settings.environments.dir,
|
|
1091
|
+
environment=env_name,
|
|
1092
|
+
environ=os.environ,
|
|
1093
|
+
)
|
|
1094
|
+
if env_name is not None
|
|
1095
|
+
else {}
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
def validate(config_dir: Path) -> None:
|
|
1099
|
+
registry = load_config(config_dir)
|
|
1100
|
+
build_check_registry(
|
|
1101
|
+
registry,
|
|
1102
|
+
inbound_bind_host=settings.inbound.bind_host,
|
|
1103
|
+
env_values=env_values,
|
|
1104
|
+
egress=settings.egress,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
try:
|
|
1108
|
+
if args.action == "upsert":
|
|
1109
|
+
raw = args.data if args.data is not None else sys.stdin.read()
|
|
1110
|
+
obj = json.loads(raw)
|
|
1111
|
+
result = connections_edit.upsert_connection(args.config, obj, validate=validate)
|
|
1112
|
+
else: # remove
|
|
1113
|
+
if not args.name:
|
|
1114
|
+
return _emit_error("--name is required for `connection remove`", as_json=args.json)
|
|
1115
|
+
result = connections_edit.remove_connection(args.config, args.name, validate=validate)
|
|
1116
|
+
except json.JSONDecodeError as exc:
|
|
1117
|
+
return _emit_error(f"invalid connection JSON: {exc}", as_json=args.json)
|
|
1118
|
+
except (WiringError, OSError) as exc:
|
|
1119
|
+
return _emit_error(str(exc), as_json=args.json)
|
|
1120
|
+
_print_json(result, compact=args.json)
|
|
1121
|
+
return 0
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _print_json(data: object, *, compact: bool) -> None:
|
|
1125
|
+
print(json.dumps(data) if compact else json.dumps(data, indent=2))
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _emit_error(message: str, *, as_json: bool) -> int:
|
|
1129
|
+
if as_json:
|
|
1130
|
+
print(json.dumps({"error": message}))
|
|
1131
|
+
else:
|
|
1132
|
+
print(f"error: {message}")
|
|
1133
|
+
return 1
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
_DISPATCH = {
|
|
1137
|
+
"serve": _serve,
|
|
1138
|
+
"init": _init,
|
|
1139
|
+
"validate": _validate,
|
|
1140
|
+
"graph": _graph,
|
|
1141
|
+
"dryrun": _dryrun,
|
|
1142
|
+
"check": _check,
|
|
1143
|
+
"connection": _connection,
|
|
1144
|
+
"generate": _generate,
|
|
1145
|
+
"hl7schema": _hl7schema,
|
|
1146
|
+
"gen-key": _gen_key,
|
|
1147
|
+
"protect-key": _protect_key,
|
|
1148
|
+
"audit-verify": _audit_verify,
|
|
1149
|
+
"rotate-key": _rotate_key,
|
|
1150
|
+
"ai-policy": _ai_policy,
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
if __name__ == "__main__":
|
|
1155
|
+
raise SystemExit(main())
|