haru-cli 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.
haru/auth/sso.py ADDED
@@ -0,0 +1,206 @@
1
+ """OAuth 2.0 Authorization Code + PKCE login against IAM Identity Center.
2
+
3
+ Implements the same flow ``aws sso login`` uses by default since AWS CLI
4
+ v2.22.0: register a public OIDC client, open the browser to the authorize
5
+ endpoint, capture the code on a 127.0.0.1 loopback listener, and exchange it
6
+ with ``CreateToken``. Only loopback redirect URIs are accepted.
7
+ """
8
+
9
+ import secrets
10
+ import time
11
+ import webbrowser
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ from typing import Any, cast
17
+ from urllib.parse import parse_qs, urlencode, urlsplit
18
+
19
+ import boto3
20
+ from botocore.exceptions import ClientError
21
+
22
+ from haru.auth.pkce import generate_pkce_pair
23
+ from haru.config.schema import AuthConfig
24
+ from haru.errors import AuthError
25
+
26
+ _LOOPBACK_HOST = "127.0.0.1"
27
+ _CALLBACK_PATH = "/oauth/callback"
28
+ _AUTHORIZE_SCOPES = "sso:account:access"
29
+ _CALLBACK_RESPONSE = (
30
+ b"<html><body><p>Sign-in complete. You can close this window and return to haru.</p>"
31
+ b"</body></html>"
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ClientRegistration:
37
+ """A registered public OIDC client."""
38
+
39
+ client_id: str
40
+ client_secret: str
41
+ expires_at: datetime
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SsoToken:
46
+ """An SSO access token plus the registration needed to refresh it."""
47
+
48
+ access_token: str
49
+ refresh_token: str | None
50
+ expires_at: datetime
51
+ registration: ClientRegistration
52
+
53
+
54
+ def register_client(
55
+ oidc: Any, client_name: str, start_url: str, redirect_uri: str
56
+ ) -> ClientRegistration:
57
+ """Register a public OIDC client for the authorization-code + PKCE flow."""
58
+ host = urlsplit(redirect_uri).hostname
59
+ if host != _LOOPBACK_HOST:
60
+ raise AuthError(f"Redirect URI must be a {_LOOPBACK_HOST} loopback address, got {host!r}")
61
+ try:
62
+ response = oidc.register_client(
63
+ clientName=client_name,
64
+ clientType="public",
65
+ scopes=[_AUTHORIZE_SCOPES],
66
+ redirectUris=[redirect_uri],
67
+ issuerUrl=start_url,
68
+ grantTypes=["authorization_code", "refresh_token"],
69
+ )
70
+ except ClientError as exc:
71
+ raise AuthError(f"OIDC client registration failed: {exc}") from exc
72
+ return ClientRegistration(
73
+ client_id=response["clientId"],
74
+ client_secret=response["clientSecret"],
75
+ expires_at=datetime.fromtimestamp(response["clientSecretExpiresAt"], tz=UTC),
76
+ )
77
+
78
+
79
+ def build_authorize_url(
80
+ region: str, client_id: str, redirect_uri: str, state: str, code_challenge: str
81
+ ) -> str:
82
+ """Build the IAM Identity Center authorize URL for the PKCE flow."""
83
+ query = urlencode(
84
+ {
85
+ "response_type": "code",
86
+ "client_id": client_id,
87
+ "redirect_uri": redirect_uri,
88
+ "state": state,
89
+ "code_challenge": code_challenge,
90
+ "code_challenge_method": "S256",
91
+ "scopes": _AUTHORIZE_SCOPES,
92
+ }
93
+ )
94
+ return f"https://oidc.{region}.amazonaws.com/authorize?{query}"
95
+
96
+
97
+ def exchange_code(
98
+ oidc: Any, registration: ClientRegistration, code: str, verifier: str, redirect_uri: str
99
+ ) -> SsoToken:
100
+ """Exchange an authorization code (plus PKCE verifier) for an SSO token."""
101
+ try:
102
+ response = oidc.create_token(
103
+ clientId=registration.client_id,
104
+ clientSecret=registration.client_secret,
105
+ grantType="authorization_code",
106
+ code=code,
107
+ codeVerifier=verifier,
108
+ redirectUri=redirect_uri,
109
+ )
110
+ except ClientError as exc:
111
+ raise AuthError(f"Token exchange failed: {exc}") from exc
112
+ return SsoToken(
113
+ access_token=response["accessToken"],
114
+ refresh_token=response.get("refreshToken"),
115
+ expires_at=datetime.now(UTC) + timedelta(seconds=response["expiresIn"]),
116
+ registration=registration,
117
+ )
118
+
119
+
120
+ def run_login(
121
+ config: AuthConfig,
122
+ *,
123
+ oidc: Any | None = None,
124
+ opener: Callable[[str], object] | None = None,
125
+ timeout_seconds: float = 300.0,
126
+ ) -> SsoToken:
127
+ """Run the interactive browser login and return the resulting SSO token.
128
+
129
+ Orchestrates client registration, PKCE pair generation, opening the
130
+ authorize URL, capturing the code on a loopback listener (validating the
131
+ ``state`` parameter), and the CreateToken exchange.
132
+ """
133
+ sso = config.sso
134
+ if oidc is None:
135
+ oidc = boto3.Session().client("sso-oidc", region_name=sso.sso_region)
136
+ open_url = opener if opener is not None else webbrowser.open
137
+
138
+ server = _start_loopback_server(sso.callback_port)
139
+ try:
140
+ port = server.server_address[1]
141
+ redirect_uri = f"http://{_LOOPBACK_HOST}:{port}{_CALLBACK_PATH}"
142
+ registration = register_client(oidc, sso.client_name, sso.start_url, redirect_uri)
143
+ verifier, challenge = generate_pkce_pair()
144
+ state = secrets.token_urlsafe(32)
145
+ open_url(
146
+ build_authorize_url(
147
+ sso.sso_region, registration.client_id, redirect_uri, state, challenge
148
+ )
149
+ )
150
+ code, returned_state = _await_callback(server, timeout_seconds)
151
+ if not secrets.compare_digest(returned_state, state):
152
+ raise AuthError("State mismatch in OAuth callback; aborting login")
153
+ return exchange_code(oidc, registration, code, verifier, redirect_uri)
154
+ finally:
155
+ server.server_close()
156
+
157
+
158
+ class _CallbackServer(HTTPServer):
159
+ """Loopback HTTP server that records the OAuth callback query parameters."""
160
+
161
+ callback_params: dict[str, list[str]] | None = None
162
+
163
+
164
+ class _CallbackHandler(BaseHTTPRequestHandler):
165
+ """Handles the single OAuth redirect request on the loopback listener."""
166
+
167
+ def do_GET(self) -> None:
168
+ parsed = urlsplit(self.path)
169
+ if parsed.path != _CALLBACK_PATH:
170
+ self.send_error(404)
171
+ return
172
+ server = cast(_CallbackServer, self.server)
173
+ server.callback_params = parse_qs(parsed.query)
174
+ self.send_response(200)
175
+ self.send_header("Content-Type", "text/html")
176
+ self.end_headers()
177
+ self.wfile.write(_CALLBACK_RESPONSE)
178
+
179
+ def log_message(self, format: str, *args: object) -> None:
180
+ """Silence default request logging; callback URLs must never be logged."""
181
+
182
+
183
+ def _start_loopback_server(port: int) -> _CallbackServer:
184
+ """Bind the loopback callback server (port 0 selects an ephemeral port)."""
185
+ try:
186
+ return _CallbackServer((_LOOPBACK_HOST, port), _CallbackHandler)
187
+ except OSError as exc:
188
+ raise AuthError(f"Could not bind loopback callback port {port}: {exc}") from exc
189
+
190
+
191
+ def _await_callback(server: _CallbackServer, timeout_seconds: float) -> tuple[str, str]:
192
+ """Wait for the OAuth redirect and return its ``(code, state)`` pair."""
193
+ deadline = time.monotonic() + timeout_seconds
194
+ server.timeout = 1.0
195
+ while server.callback_params is None:
196
+ if time.monotonic() >= deadline:
197
+ raise AuthError("Timed out waiting for the browser sign-in to complete")
198
+ server.handle_request()
199
+ params = server.callback_params
200
+ if "error" in params:
201
+ raise AuthError(f"Authorization failed: {params['error'][0]}")
202
+ codes = params.get("code")
203
+ states = params.get("state")
204
+ if not codes or not states:
205
+ raise AuthError("OAuth callback is missing the code or state parameter")
206
+ return codes[0], states[0]
haru/cli.py ADDED
@@ -0,0 +1,20 @@
1
+ """Click command group for the haru CLI."""
2
+
3
+ import click
4
+
5
+ from haru.commands.chat import chat
6
+ from haru.commands.login import login
7
+ from haru.commands.run import run
8
+ from haru.commands.session import session
9
+
10
+
11
+ @click.group()
12
+ @click.version_option(package_name="haru-cli", prog_name="haru")
13
+ def cli() -> None:
14
+ """haru: a governed CLI for Amazon Bedrock agents."""
15
+
16
+
17
+ cli.add_command(chat)
18
+ cli.add_command(login)
19
+ cli.add_command(run)
20
+ cli.add_command(session)
@@ -0,0 +1 @@
1
+ """Click command implementations for the haru CLI."""
haru/commands/chat.py ADDED
@@ -0,0 +1,102 @@
1
+ """The ``haru chat`` command: an interactive streaming REPL."""
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from haru.agents.factory import build_agent
10
+ from haru.auth.session import build_boto3_session
11
+ from haru.commands.streaming import collect_response, surface_guardrail
12
+ from haru.config import load_config
13
+ from haru.config.schema import HaruConfig
14
+ from haru.errors import HaruError
15
+ from haru.observability.telemetry import configure_telemetry
16
+ from haru.sessions.manager import build_session_manager
17
+ from haru.tools.mcp import started_mcp_clients
18
+
19
+ _EXIT_WORDS = frozenset({"exit", "quit"})
20
+
21
+
22
+ def run_chat( # noqa: PLR0913 - keyword-only wiring points, all optional
23
+ config: HaruConfig,
24
+ agent_name: str | None,
25
+ *,
26
+ console: Console,
27
+ read_input: Callable[[str], str] = input,
28
+ prompts_root: Path | None = None,
29
+ session_id: str | None = None,
30
+ ) -> None:
31
+ """Run the interactive chat loop until the user exits."""
32
+ session = build_boto3_session(config.auth)
33
+ session_manager = (
34
+ build_session_manager(config, session_id, boto_session=session)
35
+ if session_id is not None
36
+ else None
37
+ )
38
+ with started_mcp_clients(config.mcp) as mcp_clients:
39
+ agent = build_agent(
40
+ config,
41
+ agent_name,
42
+ session,
43
+ prompts_root=prompts_root,
44
+ mcp_clients=mcp_clients,
45
+ session_manager=session_manager,
46
+ )
47
+ console.print("haru chat - type 'exit' or press Ctrl-D to leave.")
48
+ while True:
49
+ try:
50
+ line = read_input("you> ").strip()
51
+ except (EOFError, KeyboardInterrupt):
52
+ console.print("\nGoodbye.")
53
+ return
54
+ if not line:
55
+ continue
56
+ if line.lower() in _EXIT_WORDS:
57
+ console.print("Goodbye.")
58
+ return
59
+ try:
60
+ result = collect_response(
61
+ agent,
62
+ line,
63
+ lambda text: console.print(text, end="", markup=False, highlight=False),
64
+ )
65
+ except KeyboardInterrupt:
66
+ console.print("\n[interrupted]")
67
+ continue
68
+ console.print()
69
+ surface_guardrail(result, console)
70
+
71
+
72
+ @click.command()
73
+ @click.option(
74
+ "--config",
75
+ "config_path",
76
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
77
+ default=None,
78
+ help="Path to haru.yaml (default: config/haru.yaml).",
79
+ )
80
+ @click.option("--agent", "agent_name", default=None, help="Agent name from configuration.")
81
+ @click.option(
82
+ "--session-id",
83
+ "session_id",
84
+ default=None,
85
+ help="Persist and restore this conversation under the given session id.",
86
+ )
87
+ def chat(config_path: Path | None, agent_name: str | None, session_id: str | None) -> None:
88
+ """Chat interactively with a Bedrock agent (streaming)."""
89
+ console = Console()
90
+ try:
91
+ config = load_config(config_path)
92
+ configure_telemetry(config.observability)
93
+ prompts_root = config_path.parent / "prompts" if config_path is not None else None
94
+ run_chat(
95
+ config,
96
+ agent_name,
97
+ console=console,
98
+ prompts_root=prompts_root,
99
+ session_id=session_id,
100
+ )
101
+ except HaruError as exc:
102
+ raise click.ClickException(str(exc)) from exc
haru/commands/login.py ADDED
@@ -0,0 +1,34 @@
1
+ """The ``haru login`` command: interactive IAM Identity Center sign-in."""
2
+
3
+ import webbrowser
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from haru.auth.cache import write_token_cache
9
+ from haru.auth.sso import run_login
10
+ from haru.config import load_config
11
+
12
+
13
+ @click.command()
14
+ @click.option(
15
+ "--config",
16
+ "config_path",
17
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
18
+ default=None,
19
+ help="Path to haru.yaml (default: config/haru.yaml).",
20
+ )
21
+ def login(config_path: Path | None) -> None:
22
+ """Sign in to AWS IAM Identity Center via the browser (PKCE)."""
23
+ config = load_config(config_path, with_includes=False)
24
+ auth = config.auth
25
+
26
+ def _open(url: str) -> None:
27
+ click.echo("Complete sign-in in your browser:")
28
+ click.echo(url)
29
+ if auth.sso.browser:
30
+ webbrowser.open(url)
31
+
32
+ token = run_login(auth, opener=_open)
33
+ path = write_token_cache(token, auth.sso.start_url, auth.sso.sso_region)
34
+ click.echo(f"Login successful. Token cached at {path}.")
haru/commands/run.py ADDED
@@ -0,0 +1,60 @@
1
+ """The ``haru run`` command: one-shot prompt execution."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from haru.agents.factory import build_agent
9
+ from haru.auth.session import build_boto3_session
10
+ from haru.commands.streaming import collect_response, surface_guardrail
11
+ from haru.config import load_config
12
+ from haru.config.schema import HaruConfig
13
+ from haru.errors import HaruError
14
+ from haru.observability.telemetry import configure_telemetry
15
+ from haru.tools.mcp import started_mcp_clients
16
+
17
+
18
+ def run_prompt(
19
+ config: HaruConfig,
20
+ prompt: str,
21
+ agent_name: str | None = None,
22
+ *,
23
+ console: Console | None = None,
24
+ prompts_root: Path | None = None,
25
+ ) -> str:
26
+ """Execute ``prompt`` once and return the full response text.
27
+
28
+ Guardrail interventions are surfaced on ``console`` (stderr by default).
29
+ """
30
+ session = build_boto3_session(config.auth)
31
+ with started_mcp_clients(config.mcp) as mcp_clients:
32
+ agent = build_agent(
33
+ config, agent_name, session, prompts_root=prompts_root, mcp_clients=mcp_clients
34
+ )
35
+ chunks: list[str] = []
36
+ result = collect_response(agent, prompt, chunks.append)
37
+ surface_guardrail(result, console if console is not None else Console(stderr=True))
38
+ return "".join(chunks)
39
+
40
+
41
+ @click.command()
42
+ @click.argument("prompt")
43
+ @click.option(
44
+ "--config",
45
+ "config_path",
46
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
47
+ default=None,
48
+ help="Path to haru.yaml (default: config/haru.yaml).",
49
+ )
50
+ @click.option("--agent", "agent_name", default=None, help="Agent name from configuration.")
51
+ def run(prompt: str, config_path: Path | None, agent_name: str | None) -> None:
52
+ """Run a single prompt and print the answer."""
53
+ try:
54
+ config = load_config(config_path)
55
+ configure_telemetry(config.observability)
56
+ prompts_root = config_path.parent / "prompts" if config_path is not None else None
57
+ answer = run_prompt(config, prompt, agent_name, prompts_root=prompts_root)
58
+ except HaruError as exc:
59
+ raise click.ClickException(str(exc)) from exc
60
+ click.echo(answer)
@@ -0,0 +1,36 @@
1
+ """The ``haru session`` command group: inspect persisted conversations."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from haru.config import load_config
8
+ from haru.errors import HaruError
9
+ from haru.sessions.manager import list_sessions
10
+
11
+
12
+ @click.group()
13
+ def session() -> None:
14
+ """Manage persisted chat sessions."""
15
+
16
+
17
+ @session.command(name="list")
18
+ @click.option(
19
+ "--config",
20
+ "config_path",
21
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
22
+ default=None,
23
+ help="Path to haru.yaml (default: config/haru.yaml).",
24
+ )
25
+ def list_command(config_path: Path | None) -> None:
26
+ """List stored session ids."""
27
+ try:
28
+ config = load_config(config_path, with_includes=False) if config_path else None
29
+ session_ids = list_sessions(config)
30
+ except HaruError as exc:
31
+ raise click.ClickException(str(exc)) from exc
32
+ if not session_ids:
33
+ click.echo("No stored sessions.")
34
+ return
35
+ for session_id in session_ids:
36
+ click.echo(session_id)
@@ -0,0 +1,34 @@
1
+ """Shared streaming helpers for the chat and run commands."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+
9
+ _GUARDRAIL_STOPS = frozenset({"guardrail_intervened", "content_filtered"})
10
+
11
+
12
+ def collect_response(agent: Any, prompt: str, on_text: Callable[[str], None]) -> Any:
13
+ """Stream a response, forwarding text chunks to ``on_text``.
14
+
15
+ Returns the final AgentResult (or None if the stream produced no result
16
+ event). Delegates the agent loop entirely to Strands.
17
+ """
18
+
19
+ async def _consume() -> Any:
20
+ result: Any = None
21
+ async for event in agent.stream_async(prompt):
22
+ if "data" in event:
23
+ on_text(event["data"])
24
+ if "result" in event:
25
+ result = event["result"]
26
+ return result
27
+
28
+ return asyncio.run(_consume())
29
+
30
+
31
+ def surface_guardrail(result: Any, console: Console) -> None:
32
+ """Print a warning when the response was blocked or redacted by guardrails."""
33
+ if result is not None and getattr(result, "stop_reason", None) in _GUARDRAIL_STOPS:
34
+ console.print("[bold yellow]Guardrail intervened: the response was blocked or redacted.[/]")
@@ -0,0 +1,6 @@
1
+ """Declarative YAML configuration loading for haru-cli."""
2
+
3
+ from haru.config.loader import load_config, load_includes, resolve_env
4
+ from haru.config.schema import HaruConfig
5
+
6
+ __all__ = ["HaruConfig", "load_config", "load_includes", "resolve_env"]
haru/config/loader.py ADDED
@@ -0,0 +1,163 @@
1
+ """Load and validate layered YAML configuration.
2
+
3
+ The loader enforces three invariants before any value reaches typed models:
4
+
5
+ 1. No inline secrets: keys named ``secret``, ``password``, or ``token`` must
6
+ hold environment references (``${env:VAR}`` or ``${VAR}``), never values.
7
+ 2. Environment references resolve at load time; a missing variable fails fast.
8
+ 3. Every document validates into the frozen schema, rejecting unknown keys.
9
+ """
10
+
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+ from pydantic import BaseModel, ValidationError
18
+
19
+ from haru.config.schema import (
20
+ AgentsConfig,
21
+ GuardrailsFile,
22
+ HaruConfig,
23
+ LoggingFile,
24
+ MCPConfig,
25
+ ModelsConfig,
26
+ )
27
+ from haru.errors import ConfigError
28
+
29
+ DEFAULT_CONFIG_PATH = Path("config") / "haru.yaml"
30
+
31
+ _ENV_PATTERN = re.compile(r"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}|\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
32
+ _ENV_REFERENCE = re.compile(r"^\$\{(?:env:)?[A-Za-z_][A-Za-z0-9_]*\}$")
33
+ _SECRET_KEY_NAMES = frozenset({"secret", "password", "token"})
34
+
35
+
36
+ def resolve_env(value: str) -> str:
37
+ """Expand ``${env:VAR}`` and ``${VAR}`` references from the environment.
38
+
39
+ Raises ConfigError if a referenced variable is not set.
40
+ """
41
+
42
+ def _replace(match: re.Match[str]) -> str:
43
+ name = match.group(1) or match.group(2)
44
+ resolved = os.environ.get(name)
45
+ if resolved is None:
46
+ raise ConfigError(f"Environment variable {name!r} referenced in configuration is unset")
47
+ return resolved
48
+
49
+ return _ENV_PATTERN.sub(_replace, value)
50
+
51
+
52
+ def load_config(path: Path | None = None, *, with_includes: bool = True) -> HaruConfig:
53
+ """Load the full configuration from ``path`` (default ``config/haru.yaml``).
54
+
55
+ Parses the base document, then resolves every declared include relative to
56
+ the base file's directory. Pass ``with_includes=False`` to load only the
57
+ base document (commands that need just the ``app``/``auth`` sections).
58
+ Raises ConfigError on any missing file, malformed YAML, inline secret,
59
+ unset environment reference, or schema violation.
60
+ """
61
+ config_path = path if path is not None else DEFAULT_CONFIG_PATH
62
+ base = _validate(HaruConfig, _load_yaml(config_path), config_path)
63
+ if not with_includes:
64
+ return base
65
+ config = load_includes(base, config_path.parent)
66
+ _check_cross_references(config)
67
+ return config
68
+
69
+
70
+ def load_includes(base: HaruConfig, root: Path) -> HaruConfig:
71
+ """Return ``base`` with every declared include file loaded and validated.
72
+
73
+ Include paths are resolved relative to ``root``. Raises ConfigError with
74
+ the offending path when an include is missing or invalid.
75
+ """
76
+ includes = base.includes
77
+ if includes is None:
78
+ return base
79
+
80
+ updates: dict[str, Any] = {}
81
+ if includes.models is not None:
82
+ source = root / includes.models
83
+ updates["models"] = _validate(ModelsConfig, _load_yaml(source), source)
84
+ if includes.agents is not None:
85
+ source = root / includes.agents
86
+ updates["agents"] = _validate(AgentsConfig, _load_yaml(source), source)
87
+ if includes.mcp is not None:
88
+ source = root / includes.mcp
89
+ updates["mcp"] = _validate(MCPConfig, _load_yaml(source), source)
90
+ if includes.guardrails is not None:
91
+ source = root / includes.guardrails
92
+ updates["guardrails"] = _validate(GuardrailsFile, _load_yaml(source), source).guardrails
93
+ if includes.logging is not None:
94
+ source = root / includes.logging
95
+ logging_file = _validate(LoggingFile, _load_yaml(source), source)
96
+ updates["logging"] = logging_file.logging
97
+ updates["observability"] = logging_file.observability
98
+ return base.model_copy(update=updates)
99
+
100
+
101
+ def _load_yaml(path: Path) -> dict[str, Any]:
102
+ """Read a YAML mapping, reject inline secrets, and resolve env references."""
103
+ if not path.is_file():
104
+ raise ConfigError(f"Missing configuration file: {path}")
105
+ try:
106
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
107
+ except yaml.YAMLError as exc:
108
+ raise ConfigError(f"Malformed YAML in {path}: {exc}") from exc
109
+ if not isinstance(data, dict):
110
+ raise ConfigError(f"Top-level YAML in {path} must be a mapping")
111
+ _reject_inline_secrets(data, source=path)
112
+ resolved: dict[str, Any] = _resolve_tree(data)
113
+ return resolved
114
+
115
+
116
+ def _resolve_tree(node: Any) -> Any:
117
+ """Recursively expand environment references in every string value."""
118
+ if isinstance(node, str):
119
+ return resolve_env(node)
120
+ if isinstance(node, dict):
121
+ return {key: _resolve_tree(value) for key, value in node.items()}
122
+ if isinstance(node, list):
123
+ return [_resolve_tree(item) for item in node]
124
+ return node
125
+
126
+
127
+ def _reject_inline_secrets(node: Any, *, source: Path, key_path: str = "") -> None:
128
+ """Fail if a secret-named key holds anything but an environment reference."""
129
+ if isinstance(node, dict):
130
+ for key, value in node.items():
131
+ child = f"{key_path}.{key}" if key_path else str(key)
132
+ if str(key).lower() in _SECRET_KEY_NAMES and not (
133
+ isinstance(value, str) and _ENV_REFERENCE.match(value)
134
+ ):
135
+ raise ConfigError(
136
+ f"Inline secret at {child!r} in {source}: use an environment"
137
+ " reference such as ${env:VAR} instead"
138
+ )
139
+ _reject_inline_secrets(value, source=source, key_path=child)
140
+ elif isinstance(node, list):
141
+ for index, item in enumerate(node):
142
+ _reject_inline_secrets(item, source=source, key_path=f"{key_path}[{index}]")
143
+
144
+
145
+ def _validate[ModelT: BaseModel](model: type[ModelT], data: dict[str, Any], source: Path) -> ModelT:
146
+ """Validate ``data`` into ``model``, converting pydantic errors to ConfigError."""
147
+ try:
148
+ return model.model_validate(data)
149
+ except ValidationError as exc:
150
+ raise ConfigError(f"Invalid configuration in {source}: {exc}") from exc
151
+
152
+
153
+ def _check_cross_references(config: HaruConfig) -> None:
154
+ """Validate references that span include files (agents -> models/MCP)."""
155
+ if config.agents is None:
156
+ return
157
+ for name, agent in config.agents.agents.items():
158
+ if config.models is not None and agent.model not in config.models.models:
159
+ raise ConfigError(f"Agent {name!r} references unknown model {agent.model!r}")
160
+ if config.mcp is not None:
161
+ for server in agent.mcp_servers:
162
+ if server not in config.mcp.mcp_servers:
163
+ raise ConfigError(f"Agent {name!r} references unknown MCP server {server!r}")