Files
2026-07-13 13:12:00 +08:00

3377 lines
130 KiB
Python

"""Parse an agent image directory into an AgentSpec."""
from __future__ import annotations
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.inner.datamodel import (
DEFAULT_BASIC_USERNAME,
CredentialProxyEntry,
CredentialProxySpec,
CredentialSourceSpec,
OSEnvSandboxSpec,
OSEnvSpec,
TerminalEnvSpec,
)
from omnigent.spec.types import (
DEFAULT_ASK_TIMEOUT,
AgentSpec,
ApiKeyAuth,
BuiltinToolConfig,
CompactionConfig,
DatabricksAuth,
ExecutorSpec,
FunctionPolicySpec,
FunctionRef,
GuardrailsSpec,
InteractionConfig,
LabelDef,
LLMConfig,
LocalToolInfo,
MCPServerConfig,
ModalityConfig,
Phase,
PhaseSelector,
PolicySpec,
ProviderAuth,
RetryPolicy,
SandboxConfig,
SharePolicy,
SkillSpec,
ToolsConfig,
)
_log = logging.getLogger(__name__)
# Context files scanned in priority order when ``instructions:`` is absent.
# First file found wins (no merge).
_CONTEXT_FILE_PRIORITY: tuple[str, ...] = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
# Pattern for SKILL.md YAML frontmatter delimited by ---
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n(.*)", re.DOTALL)
class _ConfigYamlLoader(yaml.SafeLoader):
"""
SafeLoader variant that does NOT treat ``on``/``off``/
``yes``/``no`` as booleans.
Default PyYAML resolves these per the YAML 1.1 spec — a
trap for our spec because the policy system uses
``on:`` as the selector field (see POLICIES.md §3.3
implementation notes). Without this override, an author
writing ``on: [request]`` would get a dict keyed by ``True``
instead of ``"on"``. We scope the override to a dedicated
loader class so the rest of the YAML 1.1 type inference
stays intact.
YAML 1.2 drops these bool aliases entirely; this override
makes our loader YAML-1.2-aligned for the narrow set of
aliases that matter here.
"""
# Replace the YAML 1.1 bool resolver pattern with a YAML 1.2
# pattern that accepts only ``true`` / ``false`` (and their
# title/upper-case variants). Strip the old bool resolvers
# first, then add back the narrowed one.
_BOOL_TAG = "tag:yaml.org,2002:bool"
_YAML_1_2_BOOL_RE = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$")
# ``executor.config`` keys kept as their nested YAML structure instead of
# string-coerced — their consumers read the nested mapping/list shape.
_STRUCTURED_EXECUTOR_CONFIG_KEYS: frozenset[str] = frozenset()
for _ch in list(_ConfigYamlLoader.yaml_implicit_resolvers.keys()):
_ConfigYamlLoader.yaml_implicit_resolvers[_ch] = [
(tag, regexp)
for tag, regexp in _ConfigYamlLoader.yaml_implicit_resolvers[_ch]
if tag != _BOOL_TAG
]
# Re-register a narrowed bool resolver keyed on ``t`` / ``T`` /
# ``f`` / ``F`` only (the YAML 1.1 aliases keyed on o/O/y/Y/n/N
# are now gone, so those characters parse as plain strings).
# mypy flags BaseResolver.add_implicit_resolver as untyped
# (PyYAML lacks type stubs on this classmethod); the call
# is the only way to register an implicit resolver, so the
# ignore is narrowly scoped to this YAML-1.2 compatibility
# override.
_ConfigYamlLoader.add_implicit_resolver( # type: ignore[no-untyped-call]
_BOOL_TAG,
_YAML_1_2_BOOL_RE,
list("tTfF"),
)
def _parse_int_field(raw: object, field_name: str) -> int:
"""
Coerce an integer config field while rejecting YAML booleans.
Python treats ``bool`` as a subclass of ``int``. Without this guard,
values like ``false`` silently become ``0`` for fields such as
``executor.max_iterations``.
"""
if isinstance(raw, bool):
raise OmnigentError(
f"{field_name} must be an integer, got boolean {raw!r}",
code=ErrorCode.INVALID_INPUT,
)
try:
return int(raw)
except (TypeError, ValueError) as exc:
raise OmnigentError(
f"{field_name} must be an integer, got {raw!r}",
code=ErrorCode.INVALID_INPUT,
) from exc
def _parse_float_field(raw: object, field_name: str) -> float:
"""
Coerce a numeric config field while rejecting YAML booleans.
``float(True)`` becomes ``1.0`` in Python, which is not a useful
interpretation for timing and threshold fields.
"""
if isinstance(raw, bool):
raise OmnigentError(
f"{field_name} must be a number, got boolean {raw!r}",
code=ErrorCode.INVALID_INPUT,
)
try:
return float(raw)
except (TypeError, ValueError) as exc:
raise OmnigentError(
f"{field_name} must be a number, got {raw!r}",
code=ErrorCode.INVALID_INPUT,
) from exc
def parse(root: Path, *, expand_env: bool = True) -> AgentSpec:
"""
Parse an agent image directory into an :class:`AgentSpec`.
:param root: Path to the agent image directory. Must contain
``config.yaml``.
:param expand_env: Whether to expand ``${VAR}`` references in
connection blocks and MCP headers. ``True`` (default) for
deploy/runtime — raises on unresolved vars. ``False`` for
scaffolding/validation where env vars may not yet be set.
:returns: A fully populated :class:`AgentSpec` (not yet
validated).
:raises OmnigentError: If ``config.yaml`` is not valid YAML,
has structural issues, or (when *expand_env* is ``True``)
contains unresolved env vars.
:raises FileNotFoundError: If ``config.yaml`` is missing.
"""
config_path = root / "config.yaml"
if not config_path.exists():
raise FileNotFoundError(f"config.yaml not found in {root}")
raw = yaml.load(config_path.read_text(), Loader=_ConfigYamlLoader)
if not isinstance(raw, dict):
raise OmnigentError(
f"config.yaml must be a YAML mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
spec_version = raw.get("spec_version")
if spec_version is None:
raise OmnigentError(
"config.yaml missing required field: spec_version",
code=ErrorCode.INVALID_INPUT,
)
raw_executor = raw.get("executor")
raw_llm = raw.get("llm")
raw_tools = raw.get("tools")
llm = _parse_llm(raw_llm, expand_env=expand_env)
interaction = _parse_interaction(raw.get("interaction"))
tools_config = _parse_tools_config(raw_tools, expand_env=expand_env)
executor = _parse_executor(raw_executor, expand_env=expand_env)
# ── Consolidate llm: → executor ────────────────────────────────
# ``executor.model`` and ``executor.connection`` are the primary
# source of truth. When the deprecated ``llm:`` block provides
# values that the ``executor:`` block doesn't, lift them into
# executor so all downstream code reads from one place.
# ``spec.llm`` is still populated (for internal consumers that
# need extra/retry/request_timeout) but model and connection
# are authoritative on executor.
if llm is not None:
if executor.model is None:
executor.model = llm.model
if executor.connection is None and llm.connection is not None:
executor.connection = llm.connection
# Ensure spec.llm is populated from executor fields when only the
# executor: block declares model/connection (the common case for
# user-authored YAML). Internal consumers (policy builder,
# web_fetch sub-agent) still read spec.llm for extra, retry,
# and request_timeout.
if llm is None and executor.model is not None:
llm = LLMConfig(model=executor.model, connection=executor.connection)
elif llm is not None:
# Keep llm.model and llm.connection in sync with executor
# (executor is authoritative after the lift above).
llm = LLMConfig(
model=executor.model or llm.model,
extra=llm.extra,
connection=executor.connection,
profile=llm.profile,
request_timeout=llm.request_timeout,
retry=llm.retry,
)
compaction = _parse_compaction(raw.get("compaction"))
guardrails = _parse_guardrails(raw.get("guardrails"), expand_env=expand_env)
os_env = _parse_os_env(raw.get("os_env"))
terminals = _parse_terminals(raw.get("terminals"))
params = raw.get("params", {})
# Top-level ``async:`` flag gates the LLM-callable async-dispatch
# builtins (``sys_call_async``, ``sys_read_inbox``,
# ``sys_cancel_async``). Defaults to True to match
# ``omnigent/inner/datamodel.py::AgentDef.async_enabled`` — the
# same YAML must produce the same tool surface under Omnigent mode and
# the legacy inner stack. Agents that want to suppress the surface
# declare ``async: false`` explicitly. ``bool()`` accepts YAML
# truthy/falsy values (``true`` / ``True`` / ``yes`` /
# ``false`` / ``no``) consistently.
async_enabled = bool(raw.get("async", True))
# Top-level ``timers:`` flag gates the LLM-callable timer
# builtins (``sys_timer_set``, ``sys_timer_cancel``).
# Defaults to False to match
# ``omnigent/inner/datamodel.py::AgentDef.timers`` — agents
# opt into the timer surface explicitly. See step 10 of the
# harness contract migration.
timers = bool(raw.get("timers", False))
# Top-level ``spawn:`` flag grants spawning OUTSIDE any declared
# sub-agent list: ``sys_session_create`` (existing agents by id,
# or custom bundles via config_path) plus send/close to drive the
# children. Distinct from ``tools.agents``, which permits only
# the specified sub-agent types. Defaults to False — session
# reads stay always-on, but every write grant is explicit.
spawn = bool(raw.get("spawn", False))
# Top-level ``agent_session_sharing:`` flag is the SOLE enabler of
# the ``sys_session_share`` tool, independent of ``spawn`` /
# ``tools.agents`` (and unrelated to server-API / CLI sharing).
# ``none`` (default) leaves it unregistered; ``non-public`` allows
# granting named users; ``public`` also allows ``__public__``
# anonymous read.
agent_session_sharing = _parse_share_policy(raw.get("agent_session_sharing"))
# Honor ``prompt:`` as the legacy alias for ``instructions:`` (per
# ``_OMNIGENT_SYSTEM_PROMPT_KEYS``); ``instructions:`` wins if both set.
raw_instructions = raw.get("instructions")
if raw_instructions is None:
raw_instructions = raw.get("prompt")
instructions = _resolve_instructions(root, raw_instructions)
skills = _discover_skills(root / "skills")
skills_filter = _parse_skills_filter(raw.get("skills"))
mcp_servers = _discover_mcp_servers(root / "tools" / "mcp", expand_env=expand_env)
mcp_servers = mcp_servers + _parse_inline_mcp_servers(raw_tools, expand_env=expand_env)
local_tools = _discover_local_tools(root / "tools")
sub_agents = _discover_sub_agents(root / "agents", expand_env=expand_env)
return AgentSpec(
spec_version=spec_version,
name=raw.get("name"),
description=raw.get("description"),
llm=llm,
interaction=interaction,
tools=tools_config,
executor=executor,
compaction=compaction,
guardrails=guardrails,
params=params,
instructions=instructions,
skills=skills,
skills_filter=skills_filter,
mcp_servers=mcp_servers,
local_tools=local_tools,
sub_agents=sub_agents,
async_enabled=async_enabled,
os_env=os_env,
terminals=terminals,
timers=timers,
spawn=spawn,
agent_session_sharing=agent_session_sharing,
)
def _parse_llm(
raw: dict[str, Any] | None,
*,
expand_env: bool = True,
) -> LLMConfig | None:
"""
Parse the ``llm:`` block from config.yaml into an
:class:`LLMConfig`.
:param raw: The raw ``llm:`` mapping from config.yaml, or
``None`` if the block was absent. Example:
``{"model": "openai/gpt-4o", "temperature": 0.7}``.
:param expand_env: Whether to expand ``${VAR}`` references in
the connection block. ``False`` keeps literals as-is.
:returns: A populated :class:`LLMConfig`, or ``None`` when
the ``llm:`` block is absent.
:raises OmnigentError: If the ``llm:`` block is present but
missing the required ``model`` field.
"""
if raw is None:
return None
model = raw.get("model")
if model is None:
raise OmnigentError(
"llm block present but missing required field: model",
code=ErrorCode.INVALID_INPUT,
)
# ``connection``, ``profile``, ``request_timeout``, and ``retry``
# are separated into their own typed fields; everything else is
# passed through to the LLM SDK as extra kwargs.
connection_raw = raw.get("connection")
connection: dict[str, str] | None = None
if isinstance(connection_raw, dict):
raw_dict = {str(k): str(v) for k, v in connection_raw.items()}
# Expand ${VAR} references so api_key: ${OPENAI_API_KEY} works.
# Skipped when expand_env is False (scaffolding/validation).
connection = expand_env_vars(raw_dict) if expand_env else raw_dict
profile_raw = raw.get("profile")
profile = str(profile_raw) if profile_raw is not None else None
request_timeout = (
_parse_int_field(raw["request_timeout"], "llm.request_timeout")
if "request_timeout" in raw
else 300
)
retry = _parse_retry(raw.get("retry"))
reserved = {"model", "connection", "profile", "request_timeout", "retry"}
extra = {k: v for k, v in raw.items() if k not in reserved}
return LLMConfig(
model=str(model),
extra=extra,
connection=connection,
profile=profile,
request_timeout=request_timeout,
retry=retry,
)
def _parse_interaction(
raw: dict[str, Any] | None,
) -> InteractionConfig:
"""
Parse the ``interaction:`` block from config.yaml into an
:class:`InteractionConfig`.
:param raw: The raw ``interaction:`` mapping from config.yaml,
or ``None`` if the block was absent. Example:
``{"conversational": false, "modalities": {"input":
["text", "image"]}}``.
:returns: A populated :class:`InteractionConfig`. Returns
defaults when *raw* is ``None``.
"""
if raw is None:
return InteractionConfig()
modalities_raw = raw.get("modalities")
if not isinstance(modalities_raw, dict):
modalities = ModalityConfig()
else:
modalities = ModalityConfig(
input=modalities_raw.get("input", ["text"]),
output=modalities_raw.get("output", ["text"]),
)
conversational = raw.get("conversational", True)
return InteractionConfig(
conversational=bool(conversational),
modalities=modalities,
)
def _parse_tools_config(
raw: dict[str, Any] | None,
*,
expand_env: bool = True,
) -> ToolsConfig:
"""
Parse the ``tools:`` block from config.yaml into a
:class:`ToolsConfig`.
:param raw: The raw ``tools:`` mapping from config.yaml, or
``None`` if the block was absent. Example:
``{"agents": ["summarizer", "code-reviewer"],
"timeout": 60}``.
:returns: A populated :class:`ToolsConfig`. Returns defaults
when *raw* is ``None``.
"""
if raw is None:
return ToolsConfig()
timeout = _parse_int_field(raw["timeout"], "tools.timeout") if "timeout" in raw else 60
retry = _parse_retry(raw.get("retry"))
builtins = _parse_builtin_tools(raw.get("builtins", []), expand_env=expand_env)
sandbox = _parse_sandbox_config(raw.get("sandbox"))
return ToolsConfig(
agents=raw.get("agents", []),
builtins=builtins,
timeout=timeout,
retry=retry,
sandbox=sandbox,
)
def _parse_sandbox_config(
raw: dict[str, Any] | None,
) -> SandboxConfig:
"""
Parse the ``tools.sandbox`` block from config.yaml.
Accepted settings: ``container_image`` (preferred),
``docker_image`` (deprecated alias), and
``container_runtime``. Whether sandboxing is enabled is a
runtime decision, not an agent config decision::
sandbox:
container_image: python:3.12-slim
container_runtime: podman # optional, defaults to docker
:param raw: The raw ``sandbox`` value from the ``tools``
block. ``None`` means not specified (use defaults).
:returns: A :class:`SandboxConfig`.
"""
if raw is None or not isinstance(raw, dict):
return SandboxConfig()
runtime = raw.get("container_runtime", "docker")
if runtime not in ("docker", "podman"):
raise ValueError(
f"Unsupported container_runtime {runtime!r}; expected 'docker' or 'podman'."
)
image = raw.get("container_image") or raw.get("docker_image")
return SandboxConfig(
container_image=image,
container_runtime=runtime,
)
def _parse_builtin_tools(
raw: list[str | dict[str, Any]],
*,
expand_env: bool = True,
) -> list[BuiltinToolConfig]:
"""
Parse the ``tools.builtins`` list into
:class:`BuiltinToolConfig` objects.
Each entry is either a plain string (tool name with no config)
or a dict with a ``name`` key and tool-specific config fields::
builtins:
- web_search
- name: web_search
api_key: ${GOOGLE_SEARCH_API_KEY}
engine_id: ${GOOGLE_SEARCH_ENGINE_ID}
:param raw: The raw ``builtins`` list from config.yaml.
:param expand_env: Whether to expand ``${VAR}`` references in
tool-specific config fields. ``False`` keeps literals as-is.
:returns: A list of :class:`BuiltinToolConfig` instances.
:raises OmnigentError: If a dict entry is missing ``name``.
"""
result: list[BuiltinToolConfig] = []
for entry in raw:
if isinstance(entry, str):
result.append(BuiltinToolConfig(name=entry))
elif isinstance(entry, dict):
name = entry.get("name")
if not name:
raise OmnigentError(
"Each dict entry in tools.builtins must have a 'name' field.",
code=ErrorCode.INVALID_INPUT,
)
# Everything except 'name' is tool-specific config.
raw_config = {str(k): str(v) for k, v in entry.items() if k != "name"}
config = expand_env_vars(raw_config) if expand_env else raw_config
result.append(
BuiltinToolConfig(
name=str(name),
config=config,
)
)
else:
raise OmnigentError(
f"tools.builtins entries must be strings or dicts, got {type(entry).__name__}.",
code=ErrorCode.INVALID_INPUT,
)
return result
def _parse_retry(
raw: dict[str, Any] | None,
) -> RetryPolicy:
"""
Parse a ``retry:`` block into a :class:`RetryPolicy`.
Returns defaults when *raw* is ``None`` or empty.
:param raw: The raw ``retry:`` mapping, or ``None`` if absent.
Example: ``{"max_attempts": 5, "status_codes": [429, 502]}``.
:returns: A populated :class:`RetryPolicy`.
"""
if not raw:
return RetryPolicy()
defaults = RetryPolicy()
return RetryPolicy(
max_retries=_parse_int_field(
raw.get("max_retries", defaults.max_retries),
"retry.max_retries",
),
backoff_base_s=_parse_float_field(
raw.get("backoff_base_s", defaults.backoff_base_s),
"retry.backoff_base_s",
),
backoff_max_s=_parse_float_field(
raw.get("backoff_max_s", defaults.backoff_max_s),
"retry.backoff_max_s",
),
jitter=bool(raw.get("jitter", defaults.jitter)),
timeout_per_request_s=(
_parse_float_field(raw["timeout_per_request_s"], "retry.timeout_per_request_s")
if raw.get("timeout_per_request_s") is not None
else defaults.timeout_per_request_s
),
retryable_status_codes=tuple(
_parse_int_field(c, "retry.retryable_status_codes")
for c in raw.get("retryable_status_codes", defaults.retryable_status_codes)
),
)
def _parse_executor(
raw: dict[str, Any] | None,
*,
expand_env: bool = True,
) -> ExecutorSpec:
"""
Parse the ``executor:`` block into an :class:`ExecutorSpec`.
Returns defaults (``type="omnigent"``) when *raw* is ``None``.
Lifts a top-level ``executor.profile`` into the concrete
:attr:`ExecutorSpec.profile` field for ALL executor types. For
``type == "omnigent"`` ALSO mirrors that value into
``config["profile"]`` (back-compat — the omnigent executor
reads ``config["profile"]`` today; will be migrated when the
omnigent-compat sunset lands).
:param raw: The raw ``executor:`` mapping, or ``None`` if
absent. Example: ``{"type": "omnigent"}``.
:returns: A populated :class:`ExecutorSpec`.
"""
if raw is None:
return ExecutorSpec()
etype = str(raw.get("type", "omnigent"))
# ``config`` is a free-form dict[str, Any] owned by each executor
# type. Scalar values are coerced to strings so YAML booleans /
# numbers round-trip as their string form (the omnigent
# harness/profile fields are both strings in the source YAML).
raw_config = raw.get("config")
config: dict[str, Any] = {}
if isinstance(raw_config, dict):
config = {
str(k): (v if k in _STRUCTURED_EXECUTOR_CONFIG_KEYS else str(v))
for k, v in raw_config.items()
}
# Top-level ``executor.profile`` populates the concrete
# ``ExecutorSpec.profile`` field for every executor type. For
# ``omnigent`` we ALSO mirror it into ``config["profile"]``
# so the existing omnigent executor (which still reads from
# ``config["profile"]``) keeps working until it is migrated.
profile_raw = raw.get("profile")
profile: str | None = None
if profile_raw is not None:
profile = str(profile_raw)
if etype == "omnigent" and profile is not None and "profile" not in config:
config["profile"] = profile
raw_cw = raw.get("context_window")
context_window: int | None = (
_parse_int_field(raw_cw, "executor.context_window") if raw_cw is not None else None
)
raw_model = raw.get("model")
model: str | None = str(raw_model) if raw_model is not None else None
# Parse ``executor.connection:`` — same shape as ``llm.connection:``
# (a flat dict of string key-value pairs with optional ${VAR}
# expansion). Lifted from the ``executor:`` block so connection
# config lives alongside the harness and model it belongs to.
connection_raw = raw.get("connection")
connection: dict[str, str] | None = None
if isinstance(connection_raw, dict):
raw_dict = {str(k): str(v) for k, v in connection_raw.items()}
connection = expand_env_vars(raw_dict) if expand_env else raw_dict
auth = _parse_executor_auth(raw, expand_env=expand_env)
return ExecutorSpec(
type=etype,
timeout=_parse_int_field(raw.get("timeout", 3600), "executor.timeout"),
max_iterations=_parse_int_field(
raw.get("max_iterations", 1000),
"executor.max_iterations",
),
profile=profile,
config=config,
model=model,
connection=connection,
context_window=context_window,
auth=auth,
)
def _parse_executor_auth(
raw: dict[str, Any], # type: ignore[explicit-any]
*,
expand_env: bool = True,
) -> ApiKeyAuth | DatabricksAuth | ProviderAuth | None:
"""
Parse the ``executor.auth:`` block into a typed auth dataclass.
Returns ``None`` when the ``auth:`` key is absent from the executor
block (the harness will fall back to env-var / profile defaults).
Supported types:
- ``type: api_key`` — requires ``api_key``. Env-var references
(e.g. ``$OPENAI_API_KEY``) are expanded when *expand_env* is
``True``.
- ``type: databricks`` — requires ``profile``.
- ``type: provider`` — requires ``name`` (a provider declared in
the ``providers:`` block of ``~/.omnigent/config.yaml``).
:param raw: The raw ``executor:`` mapping already read from YAML.
Example: ``{"harness": "openai-agents", "auth": {"type": "api_key",
"api_key": "$OPENAI_API_KEY"}}``.
:param expand_env: Whether to expand ``${VAR}`` / ``$VAR`` references
in the ``api_key`` value. ``True`` for runtime; ``False`` for
scaffolding / validation where env vars may not be set yet.
:returns: A populated :class:`ApiKeyAuth`, :class:`DatabricksAuth`,
or :class:`ProviderAuth`, or ``None`` when ``auth:`` is absent.
:raises OmnigentError: If the ``auth:`` block is present but
malformed (unknown type, missing required field).
"""
raw_auth = raw.get("auth")
if raw_auth is None:
return None
if not isinstance(raw_auth, dict):
raise OmnigentError(
"executor.auth must be a mapping, e.g. {type: databricks, profile: oss}",
code=ErrorCode.INVALID_INPUT,
)
auth_type = str(raw_auth.get("type", ""))
if auth_type == "api_key":
raw_key = str(raw_auth.get("api_key") or "")
if not raw_key:
raise OmnigentError(
"executor.auth.api_key is required when type is 'api_key'",
code=ErrorCode.INVALID_INPUT,
)
api_key = expand_env_vars({"api_key": raw_key})["api_key"] if expand_env else raw_key
raw_base_url = raw_auth.get("base_url")
base_url: str | None = None
if raw_base_url is not None:
raw_base_url_str = str(raw_base_url)
base_url = (
expand_env_vars({"base_url": raw_base_url_str})["base_url"]
if expand_env
else raw_base_url_str
)
return ApiKeyAuth(api_key=api_key, base_url=base_url)
if auth_type == "databricks":
profile_val = str(raw_auth.get("profile") or "")
if not profile_val:
raise OmnigentError(
"executor.auth.profile is required when type is 'databricks'",
code=ErrorCode.INVALID_INPUT,
)
return DatabricksAuth(profile=profile_val)
if auth_type == "provider":
name_val = str(raw_auth.get("name") or "")
if not name_val:
raise OmnigentError(
"executor.auth.name is required when type is 'provider'",
code=ErrorCode.INVALID_INPUT,
)
return ProviderAuth(name=name_val)
raise OmnigentError(
f"executor.auth.type must be 'api_key', 'databricks', or 'provider', got {auth_type!r}",
code=ErrorCode.INVALID_INPUT,
)
def _parse_os_env(
raw: object,
) -> OSEnvSpec | None:
"""
Parse the top-level ``os_env:`` block into an :class:`OSEnvSpec`.
Native Omnigent YAML mirrors the omnigent YAML shape so users
moving from one to the other don't have to relearn the
config surface — a top-level ``os_env:`` mapping with
``type``, ``cwd``, ``sandbox: {...}``, ``fork``, and
``start_in_scratch`` keys. See
:class:`omnigent.inner.datamodel.OSEnvSpec` for the
semantics of each field.
:param raw: The raw ``os_env:`` value from config.yaml.
Either a mapping (parsed) or absent (``None``).
Example: ``{"type": "caller_process", "cwd": ".",
"sandbox": {"type": "linux_bwrap",
"write_paths": ["."], "allow_network": False}}``.
:returns: A populated :class:`OSEnvSpec` when the block is
present, ``None`` when absent.
:raises OmnigentError: If *raw* is not a mapping, or
``start_in_scratch`` is set together with ``fork`` (those
knobs both manage the agent's writable workspace and would
fight each other), or ``start_in_scratch`` is set on a
spec whose ``sandbox.type`` is ``"none"`` (no scratch
tmpdir is created in that case so there is nothing to
chdir into).
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"os_env must be a YAML mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
sandbox = _parse_os_env_sandbox(raw.get("sandbox"))
cwd_raw = raw.get("cwd")
fork = bool(raw.get("fork", False))
start_in_scratch = bool(raw.get("start_in_scratch", False))
if start_in_scratch and fork:
raise OmnigentError(
"os_env.start_in_scratch and os_env.fork are mutually exclusive: "
"fork already provides a writable workspace by copying cwd",
code=ErrorCode.INVALID_INPUT,
)
if start_in_scratch and sandbox is not None and sandbox.type == "none":
raise OmnigentError(
"os_env.start_in_scratch requires an active sandbox; "
"sandbox.type=none does not create a scratch tmpdir",
code=ErrorCode.INVALID_INPUT,
)
return OSEnvSpec(
type=str(raw.get("type", "caller_process")),
cwd=str(cwd_raw) if cwd_raw is not None else None,
sandbox=sandbox,
fork=fork,
start_in_scratch=start_in_scratch,
)
def _parse_terminals(
raw: object,
) -> dict[str, TerminalEnvSpec] | None:
"""
Parse the top-level ``terminals:`` block into a map of
:class:`TerminalEnvSpec`.
Native Omnigent YAML mirrors the omnigent-compat ``terminals:`` shape — a
mapping of ``terminal_name`` → ``{command, args, env, os_env,
allow_cwd_override, allow_sandbox_override, scrollback, ...}`` — so a
bundle agent registers the ``sys_terminal_*`` toolkit exactly like a
compat agent. Closes the native-YAML gap left as additive follow-up in
``designs/OMNIGENT_TERMINAL_BRIDGE.md`` §3 (``_parse_terminals`` parallel to
``_parse_os_env``).
:param raw: The raw ``terminals:`` value from config.yaml — a mapping of
terminal name → config, or absent (``None``). Example:
``{"claude_code": {"command": "isaac", "allow_cwd_override": True,
"os_env": {"type": "caller_process", "sandbox": {"type": "none"}}}}``.
:returns: Map of terminal name → :class:`TerminalEnvSpec` when present and
non-empty, else ``None`` (so ``sys_terminal_*`` stays unregistered).
:raises OmnigentError: If ``terminals`` (or any entry) is not a mapping,
or an entry's ``args`` / ``env`` are the wrong type.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"terminals must be a YAML mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
terminals: dict[str, TerminalEnvSpec] = {}
for name, entry in raw.items():
if not isinstance(entry, dict):
raise OmnigentError(
f"terminals.{name} must be a YAML mapping, got {type(entry).__name__}",
code=ErrorCode.INVALID_INPUT,
)
args_raw = entry.get("args") or []
env_raw = entry.get("env") or {}
if not isinstance(args_raw, list):
raise OmnigentError(
f"terminals.{name}.args must be a list", code=ErrorCode.INVALID_INPUT
)
if not isinstance(env_raw, dict):
raise OmnigentError(
f"terminals.{name}.env must be a mapping", code=ErrorCode.INVALID_INPUT
)
# os_env may be a nested mapping (parsed like top-level os_env), the
# literal string "inherit", or absent.
raw_os_env = entry.get("os_env")
os_env = raw_os_env if isinstance(raw_os_env, str) else _parse_os_env(raw_os_env)
terminals[name] = TerminalEnvSpec(
command=entry.get("command"),
args=[str(a) for a in args_raw],
env={str(k): str(v) for k, v in env_raw.items()},
os_env=os_env,
allow_cwd_override=bool(entry.get("allow_cwd_override", False)),
allow_sandbox_override=bool(entry.get("allow_sandbox_override", False)),
log_file=entry.get("log_file"),
scrollback=_parse_int_field(
entry.get("scrollback", 10000),
f"terminals.{name}.scrollback",
),
session_prefix=str(entry.get("session_prefix", "omni_")),
tmux_allow_passthrough=bool(entry.get("tmux_allow_passthrough", False)),
tmux_start_on_attach=bool(entry.get("tmux_start_on_attach", False)),
)
return terminals or None
def _parse_os_env_sandbox(
raw: object,
) -> OSEnvSandboxSpec | None:
"""
Parse the ``os_env.sandbox:`` block into an
:class:`OSEnvSandboxSpec`.
:param raw: The raw ``sandbox:`` value from the
``os_env:`` mapping. Either a mapping (parsed) or
absent (``None``). Example:
``{"type": "linux_bwrap", "read_paths": ["/usr"],
"write_paths": ["."], "write_files":
["/home/me/.claude.json"], "cwd_allow_hidden": [".venv",
".git"], "cwd_hidden_scan_max_entries": 100000,
"cwd_hidden_scan_overflow": "warn",
"env_passthrough": ["AWS_PROFILE", "GITHUB_TOKEN"],
"allow_network": False}``.
:returns: A populated :class:`OSEnvSandboxSpec` when the
block is present, ``None`` when absent.
:raises OmnigentError: If *raw* is not a mapping, or
``cwd_allow_hidden`` is not a list of strings, or any
``cwd_allow_hidden`` entry contains a path separator, or
``cwd_hidden_scan_max_entries`` is not a positive integer,
or ``cwd_hidden_scan_overflow`` is not one of ``"error"``,
``"warn"``, ``"unlimited"``, or ``env_passthrough`` is not
a list of POSIX environment variable names.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"os_env.sandbox must be a YAML mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
read_paths_raw = raw.get("read_paths")
write_paths_raw = raw.get("write_paths")
write_files_raw = raw.get("write_files")
cwd_allow_hidden = _parse_cwd_allow_hidden(raw.get("cwd_allow_hidden"))
max_entries = _parse_cwd_hidden_scan_max_entries(raw.get("cwd_hidden_scan_max_entries"))
overflow = _parse_cwd_hidden_scan_overflow(raw.get("cwd_hidden_scan_overflow"))
env_passthrough = _parse_env_passthrough(raw.get("env_passthrough"))
egress_rules = _parse_egress_rules(raw.get("egress_rules"))
raw_type = raw.get("type")
if raw_type is None:
# No ``type:`` field in the sandbox block -- resolve via the
# platform default (the same logic that fires when ``sandbox:``
# is omitted entirely). On Linux this picks ``linux_bwrap``
# when bwrap is on PATH, else ``none``; on macOS it
# picks ``darwin_seatbelt``.
from omnigent.inner.sandbox import _default_sandbox_for_platform
sandbox_type = _default_sandbox_for_platform().type
else:
sandbox_type = str(raw_type)
if egress_rules and sandbox_type not in ("linux_bwrap", "darwin_seatbelt"):
raise OmnigentError(
"os_env.sandbox.egress_rules requires sandbox.type=linux_bwrap "
"(Linux) or sandbox.type=darwin_seatbelt (macOS) for hard "
"network enforcement: those backends restrict network access "
"at spawn time so the MITM proxy is the only egress path. "
f"Got sandbox.type={sandbox_type!r}.",
code=ErrorCode.INVALID_INPUT,
)
credential_proxy = _parse_credential_proxy(raw.get("credential_proxy"))
if credential_proxy is not None and sandbox_type not in ("linux_bwrap", "darwin_seatbelt"):
raise OmnigentError(
"os_env.sandbox.credential_proxy requires sandbox.type=linux_bwrap "
"(Linux) or sandbox.type=darwin_seatbelt (macOS) so credentials are "
"bound to a hardened helper boundary. "
f"Got sandbox.type={sandbox_type!r}.",
code=ErrorCode.INVALID_INPUT,
)
if credential_proxy is not None and not egress_rules:
raise OmnigentError(
"os_env.sandbox.credential_proxy requires os_env.sandbox.egress_rules: "
"the MITM egress proxy is what swaps the synthetic placeholder for the "
"real credential and rejects placeholder leaks, so it must be active.",
code=ErrorCode.INVALID_INPUT,
)
macos_reason = _credential_proxy_macos_unsupported_reason(credential_proxy, sandbox_type)
if macos_reason is not None:
raise OmnigentError(macos_reason, code=ErrorCode.INVALID_INPUT)
allow_private = raw.get("egress_allow_private_destinations", False)
if not isinstance(allow_private, bool):
raise OmnigentError(
"os_env.sandbox.egress_allow_private_destinations must be a "
f"boolean, got {type(allow_private).__name__}",
code=ErrorCode.INVALID_INPUT,
)
return OSEnvSandboxSpec(
type=sandbox_type,
read_paths=[str(p) for p in read_paths_raw] if read_paths_raw is not None else None,
write_paths=[str(p) for p in write_paths_raw] if write_paths_raw is not None else None,
write_files=[str(p) for p in write_files_raw] if write_files_raw is not None else None,
allow_network=bool(raw.get("allow_network", True)),
cwd_allow_hidden=cwd_allow_hidden,
cwd_hidden_scan_max_entries=max_entries,
cwd_hidden_scan_overflow=overflow,
env_passthrough=env_passthrough,
egress_rules=egress_rules,
egress_allow_private_destinations=allow_private,
credential_proxy=credential_proxy,
)
def _parse_cwd_allow_hidden(raw: object) -> list[str] | None:
"""
Parse and validate the ``cwd_allow_hidden:`` field of
``os_env.sandbox``.
Each entry must be a single path component (no ``/``, ``\\``,
or ``.`` / ``..`` traversal) so a misconfigured spec can't punch
a hole through arbitrary subdirectories of cwd. The bwrap backend
looks each entry up in ``cwd.iterdir()`` directly; sanitising
here keeps the resolver simple and the failure mode loud at
parse time rather than at runtime.
:param raw: Raw value from the YAML, e.g. ``[".venv", ".git"]``,
or ``None`` when the field is absent.
:returns: List of validated component names, or ``None`` when
``raw`` is ``None`` (the resolver will then apply the
backend's documented default).
:raises OmnigentError: If ``raw`` isn't a list, contains a
non-string entry, or contains an entry with a path separator
or traversal component.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise OmnigentError(
f"os_env.sandbox.cwd_allow_hidden must be a list, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
sanitized: list[str] = []
for entry in raw:
if not isinstance(entry, str):
raise OmnigentError(
"os_env.sandbox.cwd_allow_hidden entries must be strings, "
f"got {type(entry).__name__}: {entry!r}",
code=ErrorCode.INVALID_INPUT,
)
if not entry:
raise OmnigentError(
"os_env.sandbox.cwd_allow_hidden entries must not be empty strings",
code=ErrorCode.INVALID_INPUT,
)
if "/" in entry or "\\" in entry or entry in (".", ".."):
raise OmnigentError(
"os_env.sandbox.cwd_allow_hidden entries must be single path "
f"components (no separators or '.'/'..'): {entry!r}",
code=ErrorCode.INVALID_INPUT,
)
sanitized.append(entry)
return sanitized
_CWD_HIDDEN_SCAN_OVERFLOW_MODES = ("error", "warn", "unlimited")
def _parse_cwd_hidden_scan_max_entries(raw: object) -> int:
"""
Parse ``os_env.sandbox.cwd_hidden_scan_max_entries``.
Falls back to the dataclass default (50000) when the field is
absent. Rejects non-integers and non-positive values at parse
time so a misconfiguration surfaces immediately rather than at
spawn time.
YAML readers occasionally hand us ``True`` / ``False`` for
fields the author meant as numbers; the explicit ``bool``
rejection below catches that.
:param raw: Raw value from the YAML, e.g. ``100000`` or ``None``.
:returns: Validated positive integer, or the dataclass default
when ``raw`` is ``None``.
:raises OmnigentError: If ``raw`` is not an int or is not
strictly positive.
"""
if raw is None:
return OSEnvSandboxSpec.__dataclass_fields__["cwd_hidden_scan_max_entries"].default
if isinstance(raw, bool) or not isinstance(raw, int):
raise OmnigentError(
"os_env.sandbox.cwd_hidden_scan_max_entries must be an integer, "
f"got {type(raw).__name__}: {raw!r}",
code=ErrorCode.INVALID_INPUT,
)
if raw <= 0:
raise OmnigentError(
f"os_env.sandbox.cwd_hidden_scan_max_entries must be > 0, got {raw}",
code=ErrorCode.INVALID_INPUT,
)
return raw
def _parse_cwd_hidden_scan_overflow(raw: object) -> str:
"""
Parse ``os_env.sandbox.cwd_hidden_scan_overflow``.
Falls back to the dataclass default (``"warn"``) when the field
is absent — a partial best-effort mask plus a ``CRITICAL`` log
line, which beats blocking every spawn on workspaces (notably
ones with ``node_modules``) that routinely exceed the cap. Set
``"error"`` explicitly for untrusted trees. Rejects any value not
in :data:`_CWD_HIDDEN_SCAN_OVERFLOW_MODES`.
:param raw: Raw value from the YAML, e.g. ``"warn"`` or ``None``.
:returns: One of ``"error"``, ``"warn"``, ``"unlimited"``.
:raises OmnigentError: If ``raw`` is not one of the supported
modes.
"""
if raw is None:
return OSEnvSandboxSpec.__dataclass_fields__["cwd_hidden_scan_overflow"].default
if not isinstance(raw, str) or raw not in _CWD_HIDDEN_SCAN_OVERFLOW_MODES:
raise OmnigentError(
"os_env.sandbox.cwd_hidden_scan_overflow must be one of "
f"{list(_CWD_HIDDEN_SCAN_OVERFLOW_MODES)}, got {raw!r}",
code=ErrorCode.INVALID_INPUT,
)
return raw
# POSIX-portable environment variable name: starts with a letter or
# underscore, followed by letters, digits, or underscores. We don't
# accept anything weirder because (a) anything outside this is almost
# certainly a typo or attack surface, and (b) the env var name will
# be passed straight to ``os.execve``, which interprets ``=`` as the
# name/value separator.
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _parse_env_passthrough(raw: object) -> list[str] | None:
"""
Parse and validate the ``env_passthrough:`` field of
``os_env.sandbox``.
Each entry must be a syntactically valid POSIX environment
variable name (``[A-Za-z_][A-Za-z0-9_]*``) so we can pass it
straight to ``os.execve`` and so that an entry containing ``=``
or other shell-meaningful characters can't smuggle a *value*
through the *name* slot.
Validation happens here at parse time so a misconfigured spec
fails immediately rather than silently passing through a bogus
name (which would be a no-op at spawn time and silently
weaken whatever the user thought they were granting).
:param raw: Raw value from the YAML, e.g.
``["AWS_PROFILE", "GITHUB_TOKEN"]``, or ``None`` when the
field is absent.
:returns: List of validated env-var names, or ``None`` when
``raw`` is ``None`` (the helper will then inherit only the
always-passed defaults).
:raises OmnigentError: If ``raw`` isn't a list, contains a
non-string entry, or contains an entry that isn't a valid
POSIX env var name.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise OmnigentError(
f"os_env.sandbox.env_passthrough must be a list, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
sanitized: list[str] = []
for entry in raw:
if not isinstance(entry, str):
raise OmnigentError(
"os_env.sandbox.env_passthrough entries must be strings, "
f"got {type(entry).__name__}: {entry!r}",
code=ErrorCode.INVALID_INPUT,
)
if not _ENV_VAR_NAME_RE.match(entry):
raise OmnigentError(
"os_env.sandbox.env_passthrough entries must be POSIX "
"environment variable names "
f"(letters/digits/underscore, not starting with a digit): {entry!r}",
code=ErrorCode.INVALID_INPUT,
)
sanitized.append(entry)
return sanitized
def _parse_egress_rules(raw: object) -> list[str] | None:
"""
Parse and validate the ``egress_rules:`` field of
``os_env.sandbox``.
Each entry is validated at parse time via
:func:`~omnigent.inner.egress.rules.parse_rule` so syntax
errors surface immediately rather than at proxy start time.
:param raw: The raw value from the YAML mapping. ``None``
means "no egress filtering".
:returns: A list of validated rule strings, or ``None``.
:raises OmnigentError: If the value isn't a list or any
rule fails to parse.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise OmnigentError(
f"os_env.sandbox.egress_rules must be a list, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
if not raw:
return None
from omnigent.inner.egress.rules import parse_rule
validated: list[str] = []
for i, entry in enumerate(raw):
if not isinstance(entry, str):
raise OmnigentError(
"os_env.sandbox.egress_rules entries must be strings, "
f"got {type(entry).__name__} at index {i}: {entry!r}",
code=ErrorCode.INVALID_INPUT,
)
try:
parse_rule(entry)
except ValueError as exc:
raise OmnigentError(
f"os_env.sandbox.egress_rules[{i}] is invalid: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
validated.append(entry)
return validated
# YAML ``credential_proxy[*].type`` values are validated by the
# ``Literal`` on :class:`_CredentialProxyItemModel`. ``https_*`` are
# low-level primitives that work for any SaaS; ``git_https`` /
# ``gh_basic`` are presets that auto-wire git / the GitHub CLI.
# ``gh_basic`` hosts when ``targets`` is omitted: the git host plus the
# REST/GraphQL API host.
_GH_BASIC_DEFAULT_TARGETS = ("github.com", "api.github.com")
# Env vars the GitHub CLI reads for its API token; both are set to the
# synthetic placeholder so ``gh api`` authenticates through the proxy.
_GH_TOKEN_ENV_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
class _CredentialSourceModel(BaseModel):
"""Pydantic boundary model for a ``credential_proxy[*].source`` mapping.
The secret origin is a structured single-key mapping —
``{env: VAR}``, ``{file: path}``, or ``{command: cmd}`` — rather than
a prefix-encoded string. Exactly one key must be set. Pydantic
validates the shape here; :meth:`to_spec` converts it to the internal
:class:`CredentialSourceSpec` dataclass the runtime consumes.
:param env: Parent environment variable name carrying the secret,
e.g. ``"OA_TEST_GITHUB_PAT"``.
:param file: File path (``~`` expanded at resolution time) holding the
secret, e.g. ``"~/.config/tokens/github_pat.txt"``.
:param command: Shell command whose stdout is the secret, e.g.
``"gh auth token"``.
"""
model_config = ConfigDict(extra="forbid")
env: str | None = None
file: str | None = None
command: str | None = None
@model_validator(mode="after")
def _exactly_one_source(self) -> _CredentialSourceModel:
"""
Require exactly one source key and validate its value.
:returns: ``self`` once validated.
:raises ValueError: If zero or multiple keys are set, ``env`` is
not a POSIX environment variable name, or ``file`` / ``command``
is blank.
"""
set_keys = [
name
for name, value in (("env", self.env), ("file", self.file), ("command", self.command))
if value is not None
]
if len(set_keys) != 1:
raise ValueError("source must set exactly one of 'env', 'file', or 'command'")
if self.env is not None and not _ENV_VAR_NAME_RE.match(self.env):
raise ValueError("source 'env' must be a POSIX environment variable name")
if self.file is not None and not self.file.strip():
raise ValueError("source 'file' must be a non-empty path")
if self.command is not None and not self.command.strip():
raise ValueError("source 'command' must be a non-empty command")
return self
def to_spec(self) -> CredentialSourceSpec:
"""
Convert this validated model into a :class:`CredentialSourceSpec`.
:returns: The internal dataclass the runtime resolves the secret
from. Exactly one of ``env`` / ``file`` / ``command`` is set
(guaranteed by :meth:`_exactly_one_source`).
"""
if self.env is not None:
return CredentialSourceSpec(kind="env", env=self.env)
if self.file is not None:
return CredentialSourceSpec(kind="file", path=self.file.strip())
assert self.command is not None
return CredentialSourceSpec(kind="command", command=self.command.strip())
class _CredentialProxyItemModel(BaseModel):
"""Pydantic boundary model for one raw ``credential_proxy`` entry.
Validates the entry's *shape* — ``type``, ``source``, ``target`` /
``targets`` cardinality, the optional ``env`` injection shim, and the
optional Basic ``username`` — replacing the hand-rolled per-field
``isinstance`` checks. The parser then normalizes each validated model
into one or more :class:`CredentialProxyEntry` host bindings (the
domain transformation pydantic can't express: host/path splitting,
``gh_basic`` git-vs-API split, default targets). Unknown keys are
rejected (``extra="forbid"``) so typos fail loud.
:param type: Credential preset / primitive, one of ``"https_bearer"``,
``"https_basic"``, ``"git_https"``, ``"gh_basic"``.
:param source: Where the parent resolves the real secret from.
:param target: A single ``host`` or ``host/path`` binding, e.g.
``"github.com/org/repo.git"``. Mutually exclusive with ``targets``.
:param targets: A non-empty list of ``host`` / ``host/path`` bindings.
Mutually exclusive with ``target``.
:param env: Optional sandbox env var that receives the synthetic
placeholder (opt-in injection shim for credential-gating clients);
a POSIX environment variable name. Only accepted for the
``https_*`` primitives — ``git_https`` / ``gh_basic`` manage
injection themselves.
:param username: Optional Basic-auth username for ``https_basic`` /
``git_https`` (defaults to ``x-access-token``).
"""
model_config = ConfigDict(extra="forbid")
type: Literal["https_bearer", "https_basic", "git_https", "gh_basic"]
source: _CredentialSourceModel
target: str | None = None
targets: list[str] | None = None
env: str | None = None
username: str | None = None
@field_validator("env")
@classmethod
def _env_is_posix(cls, value: str | None) -> str | None:
"""
Reject an ``env`` that is not a POSIX environment variable name.
:param value: The raw ``env`` value, or ``None`` when absent.
:returns: ``value`` unchanged when valid.
:raises ValueError: If ``env`` is present but malformed.
"""
if value is not None and not _ENV_VAR_NAME_RE.match(value):
raise ValueError("env must be a POSIX environment variable name")
return value
@field_validator("username")
@classmethod
def _username_nonempty(cls, value: str | None) -> str | None:
"""
Reject an empty ``username``.
:param value: The raw ``username`` value, or ``None`` when absent.
:returns: ``value`` unchanged when valid.
:raises ValueError: If ``username`` is present but empty.
"""
if value is not None and not value:
raise ValueError("username must be a non-empty string")
return value
@model_validator(mode="after")
def _check_target_cardinality(self) -> _CredentialProxyItemModel:
"""
Enforce ``target`` / ``targets`` cardinality and per-type options.
``https_*`` and ``git_https`` require exactly one of ``target`` or
``targets``; ``gh_basic`` allows neither (it defaults to the
GitHub git + API hosts) but not both. The ``env`` shim is only
meaningful for the ``https_*`` primitives, and ``username`` only
applies to the Basic schemes.
:returns: ``self`` once validated.
:raises ValueError: On a cardinality violation or a per-type
option that does not apply.
"""
has_target = self.target is not None
has_targets = self.targets is not None
if has_targets and not self.targets:
raise ValueError("targets must be a non-empty list")
if self.type == "gh_basic":
if has_target and has_targets:
raise ValueError("gh_basic accepts at most one of 'target' or 'targets'")
elif has_target == has_targets:
raise ValueError("must declare exactly one of 'target' or 'targets'")
if self.env is not None and self.type in ("git_https", "gh_basic"):
raise ValueError(f"{self.type} does not accept an 'env' injection shim")
if self.username is not None and self.type == "https_bearer":
raise ValueError("https_bearer does not accept a 'username'")
return self
def _format_validation_error(exc: ValidationError) -> str:
"""
Render a pydantic ``ValidationError`` as one compact line.
The credential-proxy parser wraps pydantic failures in
:class:`OmnigentError` so the CLI / loader surface a single error
type. This flattens pydantic's structured errors into ``field:
message`` clauses joined by ``; ``, keyed by the dotted field
location.
:param exc: The raised pydantic validation error.
:returns: A semicolon-joined summary, e.g. ``"source: source must
set exactly one of 'env', 'file', or 'command'"``. When the
failure is on the entry root (e.g. a cardinality model
validator), the location renders as ``"(entry)"``.
"""
clauses: list[str] = []
for err in exc.errors():
loc = ".".join(str(part) for part in err["loc"]) or "(entry)"
clauses.append(f"{loc}: {err['msg']}")
return "; ".join(clauses)
def _parse_credential_proxy(raw: object) -> CredentialProxySpec | None:
"""
Parse and validate the ``credential_proxy:`` field of ``os_env.sandbox``.
Each list entry declares one of four ``type`` values and is normalized
into one or more :class:`CredentialProxyEntry` bindings. All four
default to **swap-on-access** — nothing credential-shaped enters the
sandbox; the egress proxy attaches the real credential to bound-host
requests:
- ``https_bearer``: ``target``/``targets`` + ``source`` + optional
``env``. Emits ``Authorization: Bearer <real>`` upstream.
- ``https_basic``: ``target``/``targets`` + ``source`` + optional
``env`` + optional ``username``. Emits ``Authorization: Basic <real>``.
- ``git_https``: ``target``/``targets`` + ``source`` + optional
``username``. Git over HTTPS via swap-on-access (Basic).
- ``gh_basic``: ``source`` + optional ``targets``. Swap-on-access for
the git host; injects ``GH_TOKEN`` / ``GITHUB_TOKEN`` for the API
host because ``gh`` won't call without a local token.
The optional ``env`` field is the opt-in injection shim for clients
that refuse to issue a request without a local credential.
Each entry's shape is validated by :class:`_CredentialProxyItemModel`
(pydantic) before normalization; a :class:`pydantic.ValidationError`
is re-raised as an :class:`OmnigentError` so callers see one error
type.
:param raw: Raw value from the YAML, e.g. ``[{"type": "git_https",
"target": "github.com/org/repo.git", "source": {"env": "GH_PAT"}}]``,
or ``None`` when the field is absent.
:returns: A populated :class:`CredentialProxySpec`, or ``None`` when
``raw`` is absent or an empty list.
:raises OmnigentError: If the value isn't a list, an entry fails
validation (unknown ``type``, bad ``source``, target cardinality,
etc.), or two entries bind the same host.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise OmnigentError(
f"os_env.sandbox.credential_proxy must be a list, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
if not raw:
return None
entries: list[CredentialProxyEntry] = []
for i, item in enumerate(raw):
try:
model = _CredentialProxyItemModel.model_validate(item)
except ValidationError as exc:
raise OmnigentError(
f"os_env.sandbox.credential_proxy[{i}] is invalid: "
f"{_format_validation_error(exc)}",
code=ErrorCode.INVALID_INPUT,
) from exc
source = model.source.to_spec()
if model.type == "gh_basic":
entries.extend(_normalize_gh_basic(model, source=source, index=i))
elif model.type == "https_bearer":
entries.extend(_normalize_https_bearer(model, source=source, index=i))
elif model.type == "https_basic":
entries.extend(_normalize_https_basic(model, source=source, index=i))
else: # git_https
entries.extend(_normalize_git_https(model, source=source, index=i))
if not entries:
return None
# Fail loud on conflicting host bindings. The egress proxy keys its
# rewrite table by host, so two entries binding the same host would
# silently last-win (one credential dropped). Reject it at parse time
# rather than picking a binding nondeterministically.
seen_hosts: dict[str, str] = {}
for entry in entries:
host_key = entry.host.lower()
if host_key in seen_hosts:
raise OmnigentError(
"os_env.sandbox.credential_proxy binds host "
f"{entry.host!r} more than once (also via "
f"{seen_hosts[host_key]!r}); each host may be bound by at "
"most one credential. Remove the duplicate entry.",
code=ErrorCode.INVALID_INPUT,
)
seen_hosts[host_key] = entry.host
return CredentialProxySpec(entries=entries)
def _credential_proxy_macos_unsupported_reason(
credential_proxy: CredentialProxySpec | None,
sandbox_type: str,
) -> str | None:
"""
Explain why a ``credential_proxy`` cannot work under ``darwin_seatbelt``.
The ``gh_basic`` preset emits a ``token``-scheme binding for the GitHub
API host (``api.*``) and injects ``GH_TOKEN`` / ``GITHUB_TOKEN`` so the
GitHub CLI authenticates through the egress MITM proxy. ``gh`` is a Go
binary, and Go on macOS verifies TLS against the system keychain via
Security.framework -- it ignores ``SSL_CERT_FILE`` / ``SSL_CERT_DIR``,
which is exactly how the egress proxy publishes its MITM CA to sandboxed
tools. ``gh`` therefore rejects the proxy's forged certificate and every
``gh`` call fails with ``"certificate is not trusted"``. Since
``darwin_seatbelt`` is macOS-only, the combination can never succeed, so we
reject it at parse time instead of surfacing an opaque runtime TLS error.
The ``token`` scheme is emitted only by ``gh_basic`` (see
:func:`_normalize_gh_basic`); keying on the scheme rather than re-deriving
the original ``type`` keeps this check independent of how the presets
normalize into bindings.
:param credential_proxy: Parsed credential-proxy spec, or ``None`` when the
``credential_proxy:`` field is absent.
:param sandbox_type: Resolved sandbox backend, e.g. ``"darwin_seatbelt"``
(macOS) or ``"linux_bwrap"`` (Linux).
:returns: A human-readable rejection message when a ``gh_basic`` (i.e. a
``token``-scheme) binding is configured on ``darwin_seatbelt``, else
``None``.
"""
if credential_proxy is None or sandbox_type != "darwin_seatbelt":
return None
if not any(entry.scheme == "token" for entry in credential_proxy.entries):
return None
return (
"os_env.sandbox.credential_proxy type 'gh_basic' does not work on macOS "
"(sandbox.type=darwin_seatbelt). 'gh_basic' wires the GitHub CLI 'gh', "
"which is a Go binary, and Go on macOS verifies TLS against the system "
"keychain (Security.framework) and ignores SSL_CERT_FILE -- the "
"environment variable the egress MITM proxy uses to publish its CA to "
"sandboxed tools. 'gh' therefore rejects the proxy's certificate and "
"every 'gh' call fails with 'certificate is not trusted'. Use "
"sandbox.type=linux_bwrap (Go honors SSL_CERT_FILE on Linux), or use "
"the 'https_bearer' / 'https_basic' primitives with a non-Go client "
"(curl / python / node) that trusts the proxy CA on macOS."
)
def _normalize_https_bearer(
model: _CredentialProxyItemModel,
*,
source: CredentialSourceSpec,
index: int,
) -> list[CredentialProxyEntry]:
"""
Normalize an ``https_bearer`` entry into per-host Bearer bindings.
The default is swap-on-access: a tool makes its request with no
``Authorization`` header and the proxy injects ``Bearer <real>`` for
the bound host. The optional ``env`` field is an opt-in shim for
clients that won't issue a request without a local credential — when
present, the synthetic placeholder is injected into that env var.
:param model: The validated ``https_bearer`` entry; carries
``target``/``targets`` and an optional ``env`` (the sandbox env
var that receives the synthetic placeholder).
:param source: Parsed credential source shared by every host binding.
:param index: Entry index for error messages.
:returns: One :class:`CredentialProxyEntry` per declared host, each
wiring ``Authorization: Bearer <real>`` upstream.
:raises OmnigentError: If a host fails DNS-safety validation.
"""
inject_env = [model.env] if model.env is not None else []
return [
CredentialProxyEntry(
host=host,
scheme="bearer",
source=source,
inject_env=inject_env,
)
for host in _resolve_credential_hosts(model, index=index)
]
def _normalize_https_basic(
model: _CredentialProxyItemModel,
*,
source: CredentialSourceSpec,
index: int,
) -> list[CredentialProxyEntry]:
"""
Normalize an ``https_basic`` entry into per-host Basic bindings.
Like ``https_bearer`` this defaults to swap-on-access; ``env`` is an
optional opt-in injection shim.
:param model: The validated ``https_basic`` entry; carries
``target``/``targets`` with optional ``env`` and optional
``username`` (defaults to ``x-access-token``).
:param source: Parsed credential source shared by every host binding.
:param index: Entry index for error messages.
:returns: One :class:`CredentialProxyEntry` per declared host, each
wiring ``Authorization: Basic b64(username:<real>)`` upstream.
:raises OmnigentError: If a host fails DNS-safety validation.
"""
inject_env = [model.env] if model.env is not None else []
username = model.username or DEFAULT_BASIC_USERNAME
return [
CredentialProxyEntry(
host=host,
scheme="basic",
source=source,
username=username,
inject_env=inject_env,
)
for host in _resolve_credential_hosts(model, index=index)
]
def _normalize_git_https(
model: _CredentialProxyItemModel,
*,
source: CredentialSourceSpec,
index: int,
) -> list[CredentialProxyEntry]:
"""
Normalize a ``git_https`` entry into per-host Basic bindings.
Git over HTTPS works purely via swap-on-access: git fires its
unauthenticated request and the proxy injects ``Basic
b64(username:<real>)`` for the bound host before it leaves. No env
var, no in-sandbox git credential helper, nothing credential-shaped
in the sandbox. (It is the ``https_basic`` primitive with a git-
friendly default username and no ``env`` shim.)
:param model: The validated ``git_https`` entry; carries
``target``/``targets`` with optional ``username`` (defaults to
``x-access-token``).
:param source: Parsed credential source shared by every host binding.
:param index: Entry index for error messages.
:returns: One :class:`CredentialProxyEntry` per declared host (Basic
upstream, swap-on-access).
:raises OmnigentError: If a host fails DNS-safety validation.
"""
username = model.username or DEFAULT_BASIC_USERNAME
return [
CredentialProxyEntry(
host=host,
scheme="basic",
source=source,
username=username,
)
for host in _resolve_credential_hosts(model, index=index)
]
def _normalize_gh_basic(
model: _CredentialProxyItemModel,
*,
source: CredentialSourceSpec,
index: int,
) -> list[CredentialProxyEntry]:
"""
Normalize a ``gh_basic`` entry into git + API credential bindings.
The git host (anything not prefixed ``api.``) authenticates via
swap-on-access (Basic), exactly like ``git_https``. The API host
(prefixed ``api.``, e.g. ``api.github.com``) keeps ``GH_TOKEN`` /
``GITHUB_TOKEN`` injection (the ``token`` scheme): ``gh`` refuses to
issue an API request unless it sees a token locally, so the synthetic
placeholder is injected to make it emit a request the proxy can swap.
:param model: The validated ``gh_basic`` entry; ``target``/``targets``
are optional and default to ``github.com`` + ``api.github.com``.
:param source: Parsed credential source shared by both bindings.
:param index: Entry index for error messages.
:returns: One or two :class:`CredentialProxyEntry` bindings.
:raises OmnigentError: If an explicit host fails DNS-safety validation.
"""
if model.target is not None or model.targets is not None:
hosts = _resolve_credential_hosts(model, index=index)
else:
hosts = list(_GH_BASIC_DEFAULT_TARGETS)
entries: list[CredentialProxyEntry] = []
for host in hosts:
if host.startswith("api."):
entries.append(
CredentialProxyEntry(
host=host,
scheme="token",
source=source,
inject_env=list(_GH_TOKEN_ENV_VARS),
)
)
else:
entries.append(
CredentialProxyEntry(
host=host,
scheme="basic",
source=source,
username=DEFAULT_BASIC_USERNAME,
)
)
return entries
def _resolve_credential_hosts(model: _CredentialProxyItemModel, *, index: int) -> list[str]:
"""
Resolve a validated entry's ``target`` / ``targets`` into bound hosts.
Cardinality (exactly one of ``target`` / ``targets`` for the
``https_*`` / ``git_https`` types; at most one for ``gh_basic``) is
already enforced by :class:`_CredentialProxyItemModel`; this only
splits each ``host`` / ``host/path`` value and validates the host
against the DNS grammar. Only the host component binds the credential
— path scoping is enforced by ``egress_rules``.
:param model: The validated entry model. Exactly one of ``target`` /
``targets`` is set when this is called.
:param index: Entry index for error messages.
:returns: De-duplicated, order-preserving list of lower-cased hosts.
:raises OmnigentError: If a host fails DNS-safety validation.
"""
if model.target is not None:
raw_targets = [model.target]
field_paths = [f"os_env.sandbox.credential_proxy[{index}].target"]
else:
# The model validator guarantees ``targets`` is a non-empty list
# whenever ``target`` is absent for the types that reach here.
assert model.targets is not None
raw_targets = model.targets
field_paths = [
f"os_env.sandbox.credential_proxy[{index}].targets[{j}]"
for j in range(len(model.targets))
]
hosts: list[str] = []
for raw_target, field_path in zip(raw_targets, field_paths, strict=True):
host = _parse_credential_proxy_host(raw_target, field_path=field_path)
if host not in hosts:
hosts.append(host)
return hosts
def _parse_credential_proxy_host(raw: str, *, field_path: str) -> str:
"""
Parse one ``host`` or ``host/path`` target into a validated host.
:param raw: Raw target value, e.g. ``"github.com/org/repo.git"`` or
``"api.github.com"``.
:param field_path: Human-readable path for parse errors.
:returns: The lower-cased host component.
:raises OmnigentError: If the value is empty or the host contains
characters outside the DNS grammar ``[A-Za-z0-9.-]`` (wildcards
included — credentials bind to an exact host).
"""
from omnigent.inner.egress.rules import is_dns_safe_host
if not raw.strip():
raise OmnigentError(
f"{field_path} must be a non-empty string (host or host/path), got {raw!r}",
code=ErrorCode.INVALID_INPUT,
)
host = raw.strip().split("/", 1)[0].lower()
if not is_dns_safe_host(host):
raise OmnigentError(
f"{field_path} host {host!r} must be an exact DNS hostname "
"(letters/digits/dot/hyphen, no wildcards)",
code=ErrorCode.INVALID_INPUT,
)
return host
def _parse_compaction(
raw: dict[str, Any] | None,
) -> CompactionConfig | None:
"""
Parse the ``compaction:`` block from config.yaml into a
:class:`CompactionConfig`.
:param raw: The raw ``compaction:`` mapping from config.yaml, or
``None`` if the block was absent. Example:
``{"trigger_threshold": 0.8, "recent_window": 5}``.
:returns: A populated :class:`CompactionConfig`, or ``None`` when
the ``compaction:`` block is absent.
"""
if raw is None:
return None
return CompactionConfig(
trigger_threshold=_parse_float_field(
raw.get("trigger_threshold", 0.8),
"compaction.trigger_threshold",
),
recent_window=_parse_int_field(
raw.get("recent_window", 5),
"compaction.recent_window",
),
)
def _read_contained_file(root: Path, value: str) -> str | None:
"""
Read a bundle-relative file named by *value*, only if it stays in *root*.
The instruction-file reference comes from a spec field (``instructions:``)
that, for an uploaded bundle, is attacker-controlled. Resolving symlinks
and ``..`` and confirming the target is contained in *root* prevents a
crafted spec (e.g. ``instructions: ../../etc/passwd``) from reading files
outside the bundle on the runner. A non-contained or non-existent path
returns ``None`` so the caller falls back to treating *value* as literal
instruction text — preserving the existing "missing file → inline text"
behavior for the CLI.
:param root: The bundle root directory the value is anchored to,
e.g. ``Path("/tmp/agent-bundle")``.
:param value: The single-line ``instructions:`` value, e.g.
``"prompts/system.md"``.
:returns: The file contents if *value* names a file contained within
*root*, else ``None``.
"""
candidate = root / value
try:
resolved = candidate.resolve()
if resolved.is_relative_to(root.resolve()) and resolved.is_file():
return resolved.read_text()
except OSError:
# Path too long or invalid characters — treat as inline text.
pass
return None
def _resolve_instructions(root: Path, raw_value: object) -> str | None:
"""
Resolve the instructions for an agent image.
- If ``instructions`` is set in config.yaml and the value is
a path to an existing file relative to *root*, read that
file.
- If ``instructions`` is set but is not a file path, treat
the value as inline text.
- If ``instructions`` is not set, scan ``_CONTEXT_FILE_PRIORITY``
and return the first file found (first-wins, no merge).
:param root: Path to the agent image directory.
:param raw_value: The raw ``instructions`` value from
config.yaml, or ``None`` if the key was absent. May be
a relative file path (e.g. ``"prompts/system.md"``) or
inline text.
:returns: The resolved instruction text, or ``None`` if no
instructions are available.
"""
if raw_value is not None:
text = str(raw_value)
# Only attempt file lookup for short single-line values
# that look like filenames (multiline text can't be a path).
if "\n" not in text:
contained = _read_contained_file(root, text)
if contained is not None:
return contained
return text
# Default: first-wins scan across known context files.
for filename in _CONTEXT_FILE_PRIORITY:
candidate = root / filename
try:
if candidate.is_file():
return candidate.read_text()
except OSError:
pass
return None
def _parse_share_policy(raw: object) -> SharePolicy:
"""
Parse the top-level YAML ``agent_session_sharing:`` field into a
:class:`SharePolicy`.
This flag is the sole enabler of the ``sys_session_share`` tool
(independent of ``spawn`` / ``tools.agents``). Sharing mutates
access control, so it is off by default and an unrecognized value
fails loud rather than silently disabling the feature.
Supported YAML shapes:
- field omitted / ``null`` → :attr:`SharePolicy.NONE` (default;
tool not registered).
- ``"none"`` / ``"non-public"`` / ``"public"`` → the matching
:class:`SharePolicy` member.
:param raw: The raw YAML value (already parsed). ``None`` or one
of the three policy strings, e.g. ``"non-public"``.
:returns: The resolved :class:`SharePolicy`.
:raises OmnigentError: When the value is neither ``None`` nor one
of the recognized policy strings (e.g. a boolean, a typo like
``"private"``, or a non-string).
"""
if raw is None:
return SharePolicy.NONE
try:
return SharePolicy(raw)
except ValueError:
valid = ", ".join(repr(p.value) for p in SharePolicy)
raise OmnigentError(
f"top-level agent_session_sharing: must be one of {valid}; got {raw!r}",
code=ErrorCode.INVALID_INPUT,
) from None
def _parse_skills_filter(raw: object) -> str | list[str]:
"""
Parse the top-level YAML ``skills:`` field into a host-skill
filter string or list of names.
Distinct from the bundle-side ``skills/<name>/SKILL.md`` files
discovered by :func:`_discover_skills` — that's the agent's own
bundled skills, always loaded. This filter only controls
HOST-scope skills that the harness picks up from the user's
machine (``~/.claude/skills/`` and ancestor ``.claude/skills/``
dirs of the cwd, when running with the Claude SDK harness).
Supported YAML shapes:
- field omitted / ``null`` / ``"all"`` → returns ``"all"``;
every host skill is loaded. Default.
- ``"none"`` or ``[]`` → returns ``"none"``; no host skills,
hermetic against the user's local skill library.
- ``[<name>, ...]`` → returns the list as-is; only the named
skills are exposed.
:param raw: The raw YAML value (already parsed). One of
``None``, a string, or a list.
:returns: ``"all"``, ``"none"``, or a non-empty ``list[str]``.
:raises OmnigentError: When the value isn't one of the
supported shapes (e.g. boolean, dict, integer), or list
items are non-strings, or a string isn't ``"all"`` or
``"none"``.
"""
if raw is None:
return "all"
if isinstance(raw, str):
if raw not in ("all", "none"):
raise OmnigentError(
f'top-level skills: must be "all", "none", or a list of '
f"skill names; got string {raw!r}",
code=ErrorCode.INVALID_INPUT,
)
return raw
if isinstance(raw, list):
if len(raw) == 0:
# Explicit empty list reads as "no host skills" — same as "none".
return "none"
names: list[str] = []
for item in raw:
if not isinstance(item, str):
raise OmnigentError(
f"top-level skills: list items must be strings; "
f"got {type(item).__name__} {item!r}",
code=ErrorCode.INVALID_INPUT,
)
names.append(item)
return names
raise OmnigentError(
f'top-level skills: must be "all", "none", or a list of skill '
f"names; got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
def discover_host_skills(
agent_root: Path,
skills_filter: str | list[str],
) -> list[SkillSpec]:
"""
Discover host-scope skills from ``.claude/skills/`` and
``.agents/skills/`` directories walking up from *agent_root*,
plus the user's global ``~/.claude/skills/``.
Not called by :func:`parse` — host-scope skills are a REPL
concern, not a spec concern. Callers (e.g. ``chat.py``) merge
the result into ``spec.skills`` before passing to
``run_repl``.
:param agent_root: The agent bundle's root directory.
:param skills_filter: The parsed ``skills:`` filter from the
agent spec. ``"none"`` suppresses all host skills;
``"all"`` loads everything; a list of names loads only
those.
:returns: Deduplicated list of :class:`SkillSpec` objects.
Later directories (closer to /) lose on name collision
with earlier ones (closer to agent_root).
"""
if skills_filter == "none":
return []
seen_names: set[str] = set()
skills: list[SkillSpec] = []
skipped: list[str] = []
filter_names: set[str] | None = None
if isinstance(skills_filter, list):
filter_names = set(skills_filter)
def _scan_dir(d: Path) -> None:
for spec in _discover_skills(d, skipped=skipped):
if spec.name in seen_names:
continue
if filter_names is not None and spec.name not in filter_names:
continue
seen_names.add(spec.name)
skills.append(spec)
# Walk from agent_root up to filesystem root, scanning
# .claude/skills/ and .agents/skills/ at each level.
current = agent_root.resolve()
while True:
for dotdir in (".claude", ".agents"):
candidate = current / dotdir / "skills"
if candidate.is_dir():
_scan_dir(candidate)
parent = current.parent
if parent == current:
break
current = parent
# Also scan user-global skill directories.
for dotdir in (".claude", ".agents"):
home_skills = Path.home() / dotdir / "skills"
if home_skills.is_dir():
_scan_dir(home_skills)
if skipped:
dest = getattr(sys.stderr, "_original_stderr", sys.stderr)
n = len(skipped)
print(
f"Warning: skipped {n} skill(s) with frontmatter errors:",
file=dest,
)
for detail in skipped:
print(f" - {detail}", file=dest)
print(
"Fix the YAML frontmatter in the above SKILL.md file(s) to load them.",
file=dest,
)
return skills
def _discover_skills(
skills_dir: Path,
*,
skipped: list[str] | None = None,
) -> list[SkillSpec]:
"""
Discover and parse all skills under the ``skills/`` directory.
Each subdirectory containing a ``SKILL.md`` file is parsed via
:func:`_parse_skill`.
:param skills_dir: Path to the ``skills/`` directory, e.g.
``root / "skills"``.
:param skipped: When not ``None``, enables lenient mode: YAML
parse errors and missing-frontmatter errors are caught
per-file, a human-readable message is appended to this
list, and the skill is skipped instead of aborting.
Pass ``None`` (the default) to fail loud on the first
error — used for bundled skills that the agent author
controls.
:returns: A sorted list of parsed :class:`SkillSpec` objects.
Returns an empty list if *skills_dir* does not exist.
"""
if not skills_dir.is_dir():
return []
try:
entries = sorted(skills_dir.iterdir())
except OSError as exc:
# Lenient mode (a skipped list was passed, e.g. host/plugin menu
# discovery): an unreadable skills dir must not 500 the caller —
# log and yield nothing. Strict mode (bundle parse) re-raises.
if skipped is None:
raise
_log.warning("Skipping unreadable skills dir %s: %s", skills_dir, exc)
skipped.append(f"{skills_dir}: {exc}")
return []
skills: list[SkillSpec] = []
for skill_dir in entries:
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
try:
skill = _parse_skill(skill_md)
except (OmnigentError, yaml.YAMLError) as exc:
if skipped is None:
raise
msg = f"{skill_md}: {exc}"
_log.warning("Skipping skill with bad frontmatter: %s", msg)
skipped.append(msg)
continue
skills.append(skill)
return skills
# Quoted string spellings treated as boolean false. PyYAML already parses the
# *bare* YAML 1.1 false words (``false``/``False``/``no``/``off``) to a real
# ``bool``, caught by the ``raw is False`` branch below; this set only catches
# the *quoted* forms (e.g. ``user-invocable: "no"``) that arrive as ``str``.
_FALSEY_STRINGS = frozenset({"false", "no", "off", "0"})
def _falsey_flag(raw: object) -> bool:
"""
Return whether a YAML frontmatter flag reads as boolean ``false``.
Accepts a genuine YAML bool (``raw is False`` — which already covers
the bare words ``false``/``no``/``off`` that PyYAML parses to a
``bool``) and the quoted string spellings in :data:`_FALSEY_STRINGS`
(case-insensitive, surrounding whitespace ignored) — YAML keeps a
quoted value as a ``str``, so the string branch is the one that
silently regresses without a test. Every other value (absent ⇒
caller's default, ``true``, other strings, ``0`` as int) is not
falsey.
:param raw: The raw frontmatter value, e.g. ``False``, ``"no"``,
or ``True``.
:returns: ``True`` only when *raw* means boolean false.
"""
if raw is False:
return True
return isinstance(raw, str) and raw.strip().lower() in _FALSEY_STRINGS
def _parse_skill(skill_md: Path) -> SkillSpec:
"""
Parse a single ``SKILL.md`` file into a :class:`SkillSpec`.
The file must begin with YAML frontmatter delimited by ``---``
lines, containing at least ``name`` and ``description`` keys.
:param skill_md: Path to the ``SKILL.md`` file, e.g.
``skills/code-review/SKILL.md``.
:returns: A populated :class:`SkillSpec`.
:raises OmnigentError: If the file cannot be read, or the
frontmatter is missing, malformed, or lacks required fields.
All failure modes funnel through a single exception type so
the tolerant scanner in :func:`_discover_skills` (when
``strict=False``) can catch them uniformly.
"""
try:
text = skill_md.read_text()
except (OSError, UnicodeDecodeError) as exc:
# UnicodeDecodeError (a non-UTF-8 SKILL.md) is a ValueError, not an
# OSError — funnel it through OmnigentError too so the lenient
# scanner in _discover_skills and the per-skill guards in the menu
# providers catch it and skip the file instead of 500-ing the menu.
raise OmnigentError(
f"SKILL.md could not be read: {skill_md}: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
match = _FRONTMATTER_RE.match(text)
if not match:
raise OmnigentError(
f"SKILL.md missing YAML frontmatter: {skill_md}",
code=ErrorCode.INVALID_INPUT,
)
frontmatter_str, content = match.groups()
try:
frontmatter = yaml.safe_load(frontmatter_str)
except yaml.YAMLError as exc:
raise OmnigentError(
f"SKILL.md has invalid YAML frontmatter: {skill_md}: {exc}",
code=ErrorCode.INVALID_INPUT,
) from exc
if not isinstance(frontmatter, dict):
raise OmnigentError(
f"SKILL.md frontmatter must be a YAML mapping: {skill_md}",
code=ErrorCode.INVALID_INPUT,
)
name = frontmatter.get("name")
if name is None:
raise OmnigentError(
f"SKILL.md frontmatter missing required field 'name': {skill_md}",
code=ErrorCode.INVALID_INPUT,
)
description = frontmatter.get("description")
if description is None:
raise OmnigentError(
f"SKILL.md frontmatter missing required field 'description': {skill_md}",
code=ErrorCode.INVALID_INPUT,
)
# ``user-invocable: false`` marks an internal orchestration skill that
# the user should not invoke directly; absent/true ⇒ invocable.
user_invocable = not _falsey_flag(frontmatter.get("user-invocable", True))
return SkillSpec(
name=str(name),
description=str(description),
content=content.strip(),
skill_dir=skill_md.parent,
user_invocable=user_invocable,
)
def expand_env_vars(
mapping: dict[str, str],
) -> dict[str, str]:
"""
Expand ``${VAR}`` and ``$VAR`` references in dict values
against the current process environment.
Raises :class:`OmnigentError` if any value still contains an
unresolved ``$VAR`` or ``${VAR}`` reference after expansion.
This catches typos and missing environment variables at parse
time rather than silently passing literal ``${MISSING}`` to
MCP servers or LLM clients.
:param mapping: A string-to-string dict, e.g.
``{"TOKEN": "${GITHUB_TOKEN}"}``.
:returns: A new dict with expanded values.
:raises OmnigentError: If a value contains an unresolved
environment variable reference after expansion.
"""
result: dict[str, str] = {}
for key, value in mapping.items():
expanded = os.path.expandvars(value)
check_unresolved_env_vars(key, expanded)
result[key] = expanded
return result
# Matches $VAR or ${VAR} patterns that survived expansion.
# Excludes $$ (escaped dollar sign).
_UNRESOLVED_VAR_RE = re.compile(r"\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*")
def check_unresolved_env_vars(key: str, value: str) -> None:
"""
Raise if *value* contains unresolved environment variable
references.
Called after :func:`os.path.expandvars` to catch variables
that were not set in the environment. Without this check,
``os.path.expandvars`` silently passes through the literal
``${VAR}`` string, which causes hard-to-debug failures
downstream (e.g. an MCP server receiving ``$GITHUB_TOKEN``
as a literal auth token).
:param key: The dict key (for error messages), e.g.
``"GITHUB_TOKEN"``.
:param value: The expanded value to check, e.g.
``"Bearer ${MISSING}"``.
:raises OmnigentError: If *value* contains an unresolved
``$VAR`` or ``${VAR}`` reference.
"""
match = _UNRESOLVED_VAR_RE.search(value)
if match is not None:
raise OmnigentError(
f"Unresolved environment variable {match.group()!r} "
f"in config key {key!r}. Set the variable in the "
f"environment or remove the reference.",
code=ErrorCode.INVALID_INPUT,
)
_TOOLS_CONFIG_KEYS = frozenset({"agents", "builtins", "timeout", "retry", "sandbox"})
def _parse_inline_mcp_servers(
raw_tools: object,
*,
expand_env: bool = True,
) -> list[MCPServerConfig]:
"""
Extract inline ``type: mcp`` entries from the top-level
``tools:`` block of config.yaml.
The inline MCP format uses the YAML mapping key as the server
name and derives the transport from the fields present:
.. code-block:: yaml
tools:
github:
type: mcp
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
search:
type: mcp
url: https://mcp.example.com/sse
headers:
Authorization: "Bearer ${MCP_TOKEN}"
``type: mcp`` entries are those whose value is a dict containing
``type: mcp``. Standard :class:`ToolsConfig` keys (``agents``,
``builtins``, ``timeout``, ``retry``, ``sandbox``) are skipped
even when they appear as dict values.
Transport is inferred: ``command`` present → ``"stdio"``,
``url`` present → ``"http"``. Entries where neither is present
(e.g. ``databricks_server``-only Databricks MCPs) are skipped —
they don't have a local spawn or SSE endpoint to display.
:param raw_tools: The raw value of the top-level ``tools:`` key
in config.yaml. ``None`` or a non-dict value returns an empty
list without raising.
:param expand_env: Whether to expand ``${VAR}`` references in
``headers`` and ``env`` values. ``True`` (default) for
deploy/runtime; ``False`` for scaffolding/validation.
:returns: A list of :class:`MCPServerConfig` objects, one per
inline MCP entry, in YAML key order.
"""
if not isinstance(raw_tools, dict):
return []
servers: list[MCPServerConfig] = []
for key, val in raw_tools.items():
if key in _TOOLS_CONFIG_KEYS:
continue
if not isinstance(val, dict):
continue
if str(val.get("type", "")) != "mcp":
continue
name = str(key)
command = val.get("command")
url = val.get("url")
if command is not None:
transport: str = "stdio"
elif url is not None:
transport = "http"
else:
# Databricks-managed server or unknown shape — no local
# endpoint to display; skip.
continue
raw_args = val.get("args", [])
args = [str(a) for a in raw_args] if isinstance(raw_args, list) else []
raw_headers = val.get("headers", {})
if raw_headers and not isinstance(raw_headers, dict):
raise OmnigentError(
f"Inline MCP server {name!r} 'headers' must be a mapping",
code=ErrorCode.INVALID_INPUT,
)
headers = expand_env_vars(raw_headers) if expand_env and raw_headers else raw_headers
raw_env = val.get("env", {})
if raw_env and not isinstance(raw_env, dict):
raise OmnigentError(
f"Inline MCP server {name!r} 'env' must be a mapping",
code=ErrorCode.INVALID_INPUT,
)
env = expand_env_vars(raw_env) if expand_env and raw_env else raw_env
# Optional Databricks auth — resolves a bearer token at
# connection time from ~/.databrickscfg.
raw_auth = val.get("auth")
databricks_profile: str | None = None
if isinstance(raw_auth, dict) and str(raw_auth.get("type", "")) == "databricks":
raw_profile = raw_auth.get("profile")
if raw_profile is None:
raise OmnigentError(
f"Inline MCP server {name!r} auth type 'databricks' "
f"requires a 'profile' field",
code=ErrorCode.INVALID_INPUT,
)
databricks_profile = str(raw_profile)
# Optional per-server tool allow-list (the YAML ``tools:`` whitelist) —
# only these tool names are exposed to the model; ``None`` exposes all.
# Mirrors ``MCPTool.tools`` and is filtered downstream in
# server/mcp_pool.py + runner/mcp_manager.py.
raw_allow = val.get("tools")
if raw_allow is not None and not isinstance(raw_allow, list):
raise OmnigentError(
f"Inline MCP server {name!r} 'tools' must be a list of tool names",
code=ErrorCode.INVALID_INPUT,
)
tool_allowlist = [str(t) for t in raw_allow] if raw_allow else None
servers.append(
MCPServerConfig(
name=name,
transport=transport,
# str() guards against non-string YAML scalars (int, bool, etc.)
description=str(raw_desc)
if (raw_desc := val.get("description")) is not None
else None,
url=str(url) if url is not None else None,
command=str(command) if command is not None else None,
args=args,
headers=headers,
env=env,
databricks_profile=databricks_profile,
tools=tool_allowlist,
)
)
return servers
def _discover_mcp_servers(
mcp_dir: Path,
*,
expand_env: bool = True,
) -> list[MCPServerConfig]:
"""
Discover and parse all MCP server configs under
``tools/mcp/``.
Each ``.yaml`` file in the directory is parsed into an
:class:`MCPServerConfig`.
:param mcp_dir: Path to the ``tools/mcp/`` directory, e.g.
``root / "tools" / "mcp"``.
:param expand_env: Whether to expand ``${VAR}`` references in
headers. ``False`` keeps literals as-is.
:returns: A sorted list of parsed :class:`MCPServerConfig`
objects. Returns an empty list if *mcp_dir* does not
exist.
:raises OmnigentError: If any YAML file is malformed or
missing required fields (``name``, ``transport``).
"""
if not mcp_dir.is_dir():
return []
servers: list[MCPServerConfig] = []
for yaml_file in sorted(mcp_dir.glob("*.yaml")):
raw = yaml.safe_load(yaml_file.read_text())
if not isinstance(raw, dict):
raise OmnigentError(
f"MCP config must be a YAML mapping: {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
name = raw.get("name")
if name is None:
raise OmnigentError(
f"MCP config missing required field 'name': {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
transport = raw.get("transport")
if transport is None:
raise OmnigentError(
f"MCP config missing required field 'transport': {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
transport_str = str(transport)
if transport_str == "http":
servers.append(_parse_http_mcp_server(name, raw, yaml_file, expand_env=expand_env))
elif transport_str == "stdio":
servers.append(_parse_stdio_mcp_server(name, raw, yaml_file, expand_env=expand_env))
else:
raise OmnigentError(
f"MCP server {name!r} uses unsupported transport "
f"{transport!r} — must be 'http' or 'stdio': {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
return servers
def _parse_http_mcp_server(
name: object,
raw: dict[str, Any], # type: ignore[explicit-any]
yaml_file: Path,
*,
expand_env: bool,
) -> MCPServerConfig:
"""
Parse an HTTP (SSE) MCP server YAML into an :class:`MCPServerConfig`.
HTTP transport requires ``url``; ``headers`` is optional and
expanded via :func:`expand_env_vars` when *expand_env* is True.
Stdio-only fields (``command``, ``args``, ``env``, ``sandbox``)
are rejected loud — mixing transports silently would hide bugs
in the YAML.
:param name: The ``name`` field from the YAML (already validated
non-None by the caller), e.g. ``"github"``.
:param raw: Parsed YAML mapping for the MCP file, e.g.
``{"name": "github", "transport": "http", "url": "..."}``.
:param yaml_file: Path to the source file — used in error messages.
:param expand_env: Whether to expand ``${VAR}`` references in
``headers``.
:returns: A fully populated :class:`MCPServerConfig` with
``transport == "http"``.
:raises OmnigentError: If ``url`` is missing or a stdio-only
field was supplied.
"""
_reject_wrong_transport_keys(
name,
raw,
yaml_file,
disallowed=("command", "args", "env", "sandbox"),
transport_name="http",
)
url = raw.get("url")
if url is None:
raise OmnigentError(
f"MCP server {name!r} missing required field 'url': {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
return MCPServerConfig(
name=str(name),
transport="http",
url=str(url),
headers=(
expand_env_vars(raw.get("headers", {})) if expand_env else raw.get("headers", {})
),
description=raw.get("description"),
timeout=(
_parse_int_field(raw["timeout"], f"MCP server {name!r}.timeout")
if "timeout" in raw
else None
),
retry=_parse_retry(raw["retry"]) if "retry" in raw else None,
)
def _parse_stdio_mcp_server(
name: object,
raw: dict[str, Any], # type: ignore[explicit-any]
yaml_file: Path,
*,
expand_env: bool,
) -> MCPServerConfig:
"""
Parse a stdio MCP server YAML into an :class:`MCPServerConfig`.
Stdio transport requires ``command``; ``args`` and ``env`` are
optional (default empty). ``sandbox`` defaults to ``True`` — the
subprocess is srt-wrapped when possible. HTTP-only fields
(``url``, ``headers``) are rejected loud.
Environment values are expanded when *expand_env* is True so
YAML like ``env: {GITHUB_TOKEN: \"${GITHUB_TOKEN}\"}`` resolves
at parse time. ``args`` are NOT expanded — they're treated as
a literal argv (consistent with how :class:`LocalToolInfo`
treats command args).
:param name: The ``name`` field from the YAML (already validated
non-None by the caller).
:param raw: Parsed YAML mapping, e.g.
``{"name": "github", "transport": "stdio", "command": "npx",
"args": ["-y", "..."], "env": {"GITHUB_TOKEN": "${GH_TOKEN}"}}``.
:param yaml_file: Path to the source file — used in error messages.
:param expand_env: Whether to expand ``${VAR}`` references in
``env``.
:returns: A fully populated :class:`MCPServerConfig` with
``transport == "stdio"``.
:raises OmnigentError: If ``command`` is missing, ``args`` is
not a list, ``env`` is not a mapping, or an HTTP-only field
was supplied.
"""
_reject_wrong_transport_keys(
name,
raw,
yaml_file,
disallowed=("url", "headers"),
transport_name="stdio",
)
command = raw.get("command")
if command is None:
raise OmnigentError(
f"MCP server {name!r} (transport='stdio') missing required field "
f"'command': {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
raw_args = raw.get("args", [])
if not isinstance(raw_args, list):
raise OmnigentError(
f"MCP server {name!r} (transport='stdio') 'args' must be a list: {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
raw_env = raw.get("env", {})
if not isinstance(raw_env, dict):
raise OmnigentError(
f"MCP server {name!r} (transport='stdio') 'env' must be a mapping: {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
env = expand_env_vars(raw_env) if expand_env else raw_env
if "sandbox" in raw:
# Step 7: ``sandbox: <bool>`` was an AP-only no-op that
# wrapped the stdio spawn with ``srt``. srt's default
# policy blocks outbound network, which broke every
# useful MCP server, so the field is gone. Reject loud
# so authors who copy old YAMLs see the change instead
# of a silently-ignored key. Future per-MCP sandboxing
# will use a different schema (per-host outbound
# allowlists) routed through the environments primitive.
raise OmnigentError(
f"MCP server {name!r} (transport='stdio') 'sandbox' field "
f"was removed in step 7 of the harness contract migration: "
f"{yaml_file}. The previous default (srt-wrap) blocked "
f"outbound network and broke every useful MCP. Drop the "
f"key from the YAML; future sandboxing will use a "
f"per-MCP outbound-host allowlist with a different schema.",
code=ErrorCode.INVALID_INPUT,
)
return MCPServerConfig(
name=str(name),
transport="stdio",
command=str(command),
args=[str(a) for a in raw_args],
env={str(k): str(v) for k, v in env.items()},
description=raw.get("description"),
timeout=(
_parse_int_field(raw["timeout"], f"MCP server {name!r}.timeout")
if "timeout" in raw
else None
),
retry=_parse_retry(raw["retry"]) if "retry" in raw else None,
)
def _reject_wrong_transport_keys(
name: object,
raw: dict[str, Any], # type: ignore[explicit-any]
yaml_file: Path,
*,
disallowed: tuple[str, ...],
transport_name: str,
) -> None:
"""
Fail loud if an MCP YAML mixes fields from the wrong transport.
E.g. ``transport: http`` with a ``command:`` key, or
``transport: stdio`` with a ``url:`` key — both silently-ignored
shapes would hide authoring bugs. Name every offending key in
the error so the author can clean the YAML in one pass.
:param name: The MCP server's ``name`` field, used in the error
message.
:param raw: Parsed YAML mapping.
:param yaml_file: Path to the source file — used in error messages.
:param disallowed: Tuple of keys that MUST NOT appear for this
transport, e.g. ``("url", "headers")`` for stdio.
:param transport_name: Human-readable transport label for the
error message, e.g. ``"stdio"``.
:raises OmnigentError: When any *disallowed* key is present
in *raw*.
"""
offenders = [k for k in disallowed if k in raw]
if offenders:
raise OmnigentError(
f"MCP server {name!r} (transport={transport_name!r}) has "
f"wrong-transport field(s) {offenders!r}: {yaml_file}",
code=ErrorCode.INVALID_INPUT,
)
def _discover_local_tools(
tools_dir: Path,
) -> list[LocalToolInfo]:
"""
Discover local tool files under ``tools/python/`` and
``tools/typescript/``.
Tool names are derived from the file stem directly (e.g.
``arxiv_search.py`` becomes ``"arxiv_search"``). Underscores
are preserved — the tool name regex requires
``[a-zA-Z0-9_-]``.
:param tools_dir: Path to the ``tools/`` directory, e.g.
``root / "tools"``.
:returns: A sorted list of :class:`LocalToolInfo` objects
covering both Python and TypeScript tools.
"""
tools: list[LocalToolInfo] = []
for language, subdir, ext in [
("python", "python", ".py"),
("typescript", "typescript", ".ts"),
]:
lang_dir = tools_dir / subdir
if not lang_dir.is_dir():
continue
for tool_file in sorted(lang_dir.glob(f"*{ext}")):
tool_name = tool_file.stem
rel_path = str(tool_file.relative_to(tools_dir.parent))
tools.append(LocalToolInfo(name=tool_name, path=rel_path, language=language))
return tools
def _discover_sub_agents(
agents_dir: Path,
*,
expand_env: bool = True,
) -> list[AgentSpec]:
"""
Recursively discover and parse sub-agents under ``agents/``.
Each subdirectory containing a ``config.yaml`` is parsed via
:func:`parse`, producing a nested :class:`AgentSpec`.
:param agents_dir: Path to the ``agents/`` directory, e.g.
``root / "agents"``.
:param expand_env: Whether to expand ``${VAR}`` references.
Propagated to :func:`parse` for each sub-agent.
:returns: A sorted list of recursively parsed
:class:`AgentSpec` objects. Returns an empty list if
*agents_dir* does not exist.
"""
if not agents_dir.is_dir():
return []
sub_agents: list[AgentSpec] = []
for agent_dir in sorted(agents_dir.iterdir()):
if not agent_dir.is_dir():
continue
config_yaml = agent_dir / "config.yaml"
if not config_yaml.exists():
continue
sub_agents.append(parse(agent_dir, expand_env=expand_env))
return sub_agents
# ── Guardrails / policy parsers (POLICIES.md §3.3) ───────────
#
# Per POLICIES.md §13, most policy-spec errors fail LOUD at
# spec load — these helpers raise ``OmnigentError`` on
# malformed input rather than silently coercing to defaults.
# The exception is ``_parse_condition``, which permissively
# coerces scalar / list values to strings (matching omnigent
# parity for label values — see §14 of the audit).
def _parse_guardrails(
raw: dict[str, Any] | None,
*,
expand_env: bool = True,
) -> GuardrailsSpec | None:
"""
Parse the ``guardrails:`` block into a :class:`GuardrailsSpec`.
Returns ``None`` when the block is absent entirely — the
runtime builds a no-op policy engine in that case
(POLICIES.md §10 zero-policy case).
:param raw: The ``guardrails:`` mapping from config.yaml,
or ``None`` when the block was absent. Example:
``{"labels": {"integrity": {"initial": "1",
"values": ["0", "1"]}},
"policies": {"block_canada_input": {"type": "prompt",
...}}, "ask_timeout": 30}``.
:param expand_env: Whether to expand ``${VAR}`` references
in any nested ``llm.connection`` blocks (PromptPolicy
LLM overrides). Propagated to :func:`_parse_llm`.
:returns: A populated :class:`GuardrailsSpec`, or ``None``
when *raw* is ``None``.
:raises OmnigentError: On any spec-load validation
failure (unknown phases, empty ``on:`` lists, invalid
label defs, bad policy types, etc.).
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"guardrails: must be a mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
return GuardrailsSpec(
labels=_parse_label_defs(raw.get("labels")),
policies=_parse_policies(raw.get("policies"), expand_env=expand_env),
ask_timeout=_parse_guardrails_ask_timeout(
raw.get("ask_timeout", DEFAULT_ASK_TIMEOUT),
),
)
def _parse_guardrails_ask_timeout(raw: Any) -> int:
"""
Validate and coerce the spec-wide ``ask_timeout`` value.
Accepts an integer (or string that parses as one);
rejects ``<= 0`` at spec load per POLICIES.md §13. The
ambiguity between "instant DENY" and "wait forever"
drove the strict > 0 rule — both intents have explicit
paths (use a large finite number for long waits).
:param raw: Raw ``guardrails.ask_timeout:`` value.
:returns: Validated timeout in seconds.
:raises OmnigentError: On non-integer or non-positive
values.
"""
value = _parse_int_field(raw, "guardrails.ask_timeout")
if value <= 0:
raise OmnigentError(
"guardrails.ask_timeout must be > 0 "
"(omit ASK from policy action list for instant-DENY; "
"use large finite values for long waits)",
code=ErrorCode.INVALID_INPUT,
)
return value
def _parse_label_defs(
raw: dict[str, Any] | None,
) -> dict[str, LabelDef] | None:
"""
Parse the ``guardrails.labels:`` block into a dict of
:class:`LabelDef` by key.
Accepts three YAML shapes per POLICIES.md §3.1:
- Bare string: ``integrity: "1"`` → schemaless with
``initial="1"``.
- Dict (schema'd with initial):
``{initial: "1", values: [...]}``.
- Dict (schema'd without initial):
``{values: [...]}``.
:param raw: The ``labels:`` mapping, or ``None``.
:returns: Dict mapping each label key to its
:class:`LabelDef`. ``None`` when *raw* is ``None``.
:raises OmnigentError: On malformed entries — empty
dict, ``initial`` not in ``values``, etc.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"guardrails.labels: must be a mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
defs: dict[str, LabelDef] = {}
for key, entry in raw.items():
defs[str(key)] = _parse_single_label_def(str(key), entry)
return defs
def _parse_single_label_def(key: str, entry: Any) -> LabelDef:
"""
Parse one label definition entry.
:param key: The label key, used in error messages, e.g.
``"integrity"``.
:param entry: Either a string (shorthand: value becomes
``initial``) or a dict with one or more of
``initial``, ``values``.
:returns: A populated :class:`LabelDef`.
:raises OmnigentError: On any malformed value.
"""
# Bare-string shorthand: `integrity: "1"` → initial only.
if isinstance(entry, str):
return LabelDef(initial=entry)
if isinstance(entry, bool) or entry is None or isinstance(entry, int | float):
# Coerce scalar to string for shorthand form. YAML
# authors often write `: 1` expecting "1"; coercing
# matches the condition-value coercion policy elsewhere.
return LabelDef(initial=str(entry) if entry is not None else None)
if not isinstance(entry, dict):
raise OmnigentError(
f"label {key!r} must be a string or mapping, got {type(entry).__name__}",
code=ErrorCode.INVALID_INPUT,
)
if not entry:
# Empty-dict typo guard — matches POLICIES.md §13.
raise OmnigentError(
f"label {key!r} declares an empty dict — must contain at "
f"least one of `initial` or `values`",
code=ErrorCode.INVALID_INPUT,
)
initial = _coerce_label_initial(entry.get("initial"))
values = _coerce_label_values(key, entry.get("values"))
_validate_label_def_cross_fields(key, initial, values)
return LabelDef(initial=initial, values=values)
def _coerce_label_initial(raw: Any) -> str | None:
"""Coerce an ``initial:`` value to ``str | None``."""
return None if raw is None else str(raw)
def _coerce_label_values(key: str, raw: Any) -> list[str] | None:
"""
Coerce a ``values:`` list to ``list[str]`` or ``None``.
:param key: Label key, for error messages.
:param raw: Raw ``values:`` value from YAML.
:returns: Every element str-coerced; ``None`` when
*raw* is ``None``.
:raises OmnigentError: When *raw* is a non-list
non-None value.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise OmnigentError(
f"label {key!r}: `values` must be a list, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
return [str(v) for v in raw]
def _validate_label_def_cross_fields(
key: str,
initial: str | None,
values: list[str] | None,
) -> None:
"""
Enforce cross-field constraints on a :class:`LabelDef`.
Per POLICIES.md §13:
- When both ``initial`` and ``values`` are declared,
``initial`` must be in ``values``.
:param key: Label key, for error messages.
:param initial: Pre-coerced initial value.
:param values: Pre-coerced values list.
:raises OmnigentError: On any cross-field violation.
"""
if initial is not None and values is not None and initial not in values:
raise OmnigentError(
f"label {key!r}: `initial` value {initial!r} is not in declared `values` {values!r}",
code=ErrorCode.INVALID_INPUT,
)
def _parse_policies(
raw: dict[str, Any] | list[Any] | None,
*,
expand_env: bool = True,
) -> list[PolicySpec] | None:
"""
Parse the ``guardrails.policies:`` block.
YAML uses a mapping keyed by policy name (preserving
YAML declaration order, which the engine relies on per
POLICIES.md §4). Returns a list of
:class:`PolicySpec` instances in that order.
:param raw: The ``policies:`` mapping, or ``None``.
:param expand_env: Propagated to
:func:`_parse_llm` for any PromptPolicy ``llm:``
overrides.
:returns: List of policy specs, or ``None`` when *raw*
is ``None``.
:raises OmnigentError: On any malformed policy entry.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"guardrails.policies: must be a mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
policies: list[PolicySpec] = []
for name, entry in raw.items():
policies.append(
_parse_policy_spec(str(name), entry, expand_env=expand_env),
)
return policies
def _parse_policy_spec(
name: str,
data: Any,
*,
expand_env: bool = True,
) -> PolicySpec:
"""
Parse one policy's YAML block into the appropriate
:class:`PolicySpec` subclass.
Dispatches on the ``type:`` discriminator
(``"function"``, ``"prompt"``, or ``"label"``).
:param name: YAML key for this policy, used in error
messages and recorded on the spec.
:param data: Raw mapping from YAML (the value beneath
``policies.<name>:``).
:param expand_env: Propagated for any nested ``llm:``
connection overrides.
:returns: A concrete ``PolicySpec`` subclass instance.
:raises OmnigentError: On malformed data or unknown
policy type.
"""
del expand_env # Was used by _parse_prompt_policy (removed).
if not isinstance(data, dict):
raise OmnigentError(
f"policy {name!r}: must be a mapping, got {type(data).__name__}",
code=ErrorCode.INVALID_INPUT,
)
policy_type = data.get("type")
if policy_type is None:
raise OmnigentError(
f"policy {name!r}: missing required field `type` (must be 'function')",
code=ErrorCode.INVALID_INPUT,
)
if policy_type == "prompt":
raise OmnigentError(
f"policy {name!r}: type 'prompt' is no longer supported. "
f"Use type 'function' with handler "
f"'omnigent.policies.builtins.prompt.prompt_policy' instead.",
code=ErrorCode.INVALID_INPUT,
)
base_kwargs = _parse_policy_base_fields(name, data, is_function=policy_type == "function")
if policy_type == "function":
return _parse_function_policy(name, data, base_kwargs)
raise OmnigentError(
f"policy {name!r}: unknown type {policy_type!r} (must be 'function')",
code=ErrorCode.INVALID_INPUT,
)
def _parse_policy_base_fields(
name: str,
data: dict[str, Any],
*,
is_function: bool = False,
) -> dict[str, Any]:
"""
Parse the fields every policy type shares.
Factored out of ``_parse_policy_spec`` so the dispatch
function stays small. Fields: ``name``, ``on`` (with
the ``[request, response]`` default per POLICIES.md §3.1),
``condition``, and per-policy ``ask_timeout`` override.
For ``type: function`` policies (``is_function=True``) the
``on`` field is ignored — the callable self-selects which
events to handle by returning ALLOW for events it doesn't act on.
:param name: Enclosing policy name.
:param data: Raw YAML mapping for this policy.
:param is_function: ``True`` when parsing a ``type: function``
policy. Ignores the ``on:`` field and sets ``on=None``.
:returns: Kwargs dict ready to splat into any
:class:`PolicySpec` subclass constructor.
"""
if is_function:
# ``on:`` is ignored for function policies — the callable self-selects
# which events to handle by returning ALLOW for events it doesn't act on.
on_value = None
else:
on_value = _parse_on(data.get("on", ["request", "response"]), policy_name=name)
return {
"name": name,
"on": on_value,
"condition": _parse_condition(data.get("condition"), policy_name=name),
"ask_timeout": _parse_policy_ask_timeout(
data.get("ask_timeout"),
policy_name=name,
),
}
def _parse_function_policy(
name: str,
data: dict[str, Any],
base_kwargs: dict[str, Any],
) -> FunctionPolicySpec:
"""
Parse a ``type: function`` policy block.
:param name: Enclosing policy name (error messages +
recorded on the spec).
:param data: Raw YAML mapping for this policy.
:param base_kwargs: Pre-parsed fields shared across
policy types (``name``, ``on``, ``condition``,
``ask_timeout``).
:returns: A populated :class:`FunctionPolicySpec`.
:raises OmnigentError: On missing ``function:`` field
or malformed ``action`` / ``set_labels`` values.
"""
# Accept both ``function:`` and ``handler:`` for the callable path.
# ``handler`` is the proto/service-policies convention; ``function``
# is the original omnigent YAML convention.
function_raw = data.get("function") or data.get("handler")
if function_raw is None:
raise OmnigentError(
f"policy {name!r}: `function` policies require a `function:` or `handler:` field",
code=ErrorCode.INVALID_INPUT,
)
set_labels = (
_parse_writable_labels(data["set_labels"], policy_name=name)
if "set_labels" in data
else None
)
config = data.get("config")
if config is not None and not isinstance(config, dict):
raise OmnigentError(
f"policy {name!r}: 'config' must be a dict, got {type(config).__name__}",
code=ErrorCode.INVALID_INPUT,
)
return FunctionPolicySpec(
**base_kwargs,
function=_parse_function_ref(function_raw, policy_name=name),
set_labels=set_labels,
config=config,
)
def _parse_on(
raw: Any,
*,
policy_name: str,
) -> list[PhaseSelector]:
"""
Parse a policy's ``on:`` list into :class:`PhaseSelector`
entries.
YAML shapes:
- ``"request"`` → wildcard selector for the REQUEST phase.
- ``"tool_call:web_search"`` → TOOL_CALL narrowed to
one tool name.
Tool-name narrowing is rejected on REQUEST / RESPONSE phases
(only meaningful for tool_call / tool_result).
:param raw: The ``on:`` value from YAML. Must be a
non-empty list of strings.
:param policy_name: Enclosing policy name for error
messages.
:returns: List of :class:`PhaseSelector` entries, one
per YAML list element.
:raises OmnigentError: On empty list, unknown phase,
or tool-narrowing on a non-tool phase.
"""
if not isinstance(raw, list):
raise OmnigentError(
f"policy {policy_name!r}: `on:` must be a list, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
if not raw:
# POLICIES.md §13: empty `on:` creates a policy that
# never fires — reject at spec load.
raise OmnigentError(
f"policy {policy_name!r}: `on:` must contain at least one "
f"phase selector (empty list would create a policy that "
f"never fires)",
code=ErrorCode.INVALID_INPUT,
)
return [_parse_on_entry(entry, policy_name=policy_name) for entry in raw]
def _parse_on_entry(
entry: Any,
*,
policy_name: str,
) -> PhaseSelector:
"""
Parse one entry of a policy's ``on:`` list.
Handles both forms: bare ``"<phase>"`` (wildcard) and
``"<phase>:<tool_name>"`` (tool-narrowed). Tool narrowing
is rejected on phases other than TOOL_CALL / TOOL_RESULT.
:param entry: One YAML list element — must be a string.
:param policy_name: Enclosing policy name, used in error
messages.
:returns: A populated :class:`PhaseSelector`.
:raises OmnigentError: On non-string entry, empty
tool-name suffix, unknown phase, or tool narrowing
on a non-tool phase.
"""
if not isinstance(entry, str):
raise OmnigentError(
f"policy {policy_name!r}: `on:` entries must be strings, got {type(entry).__name__}",
code=ErrorCode.INVALID_INPUT,
)
if ":" not in entry:
return PhaseSelector(phase=_resolve_phase(entry, entry, policy_name=policy_name))
phase_str, tool_name = entry.split(":", 1)
if not tool_name:
raise OmnigentError(
f"policy {policy_name!r}: empty tool name in on-selector {entry!r}",
code=ErrorCode.INVALID_INPUT,
)
phase = _resolve_phase(phase_str, entry, policy_name=policy_name)
if phase not in (Phase.TOOL_CALL, Phase.TOOL_RESULT):
raise OmnigentError(
f"policy {policy_name!r}: phase {phase.value!r} "
f"cannot be narrowed by tool name; tool filters "
f"only apply to tool_call / tool_result",
code=ErrorCode.INVALID_INPUT,
)
return PhaseSelector(phase=phase, tool_name=tool_name)
def _resolve_phase(
phase_str: str,
context: str,
*,
policy_name: str,
) -> Phase:
"""
Resolve a phase-string into a :class:`Phase` enum.
:param phase_str: The phase part of the selector
(before any ``:``), e.g. ``"tool_call"``.
:param context: Full on-selector value, used verbatim in
the error message so the author can see which
element failed, e.g. ``"tool_call:web_search"``.
:param policy_name: Enclosing policy name, for error
messages.
:returns: The resolved :class:`Phase`.
:raises OmnigentError: When *phase_str* is not a
valid phase.
"""
try:
return Phase(phase_str)
except ValueError as exc:
raise OmnigentError(
f"policy {policy_name!r}: unknown phase {phase_str!r} in {context!r}"
if context != phase_str
else f"policy {policy_name!r}: unknown phase {phase_str!r}",
code=ErrorCode.INVALID_INPUT,
) from exc
def _parse_condition(
raw: Any,
*,
policy_name: str,
) -> dict[str, str | list[str]] | None:
"""
Parse a policy's ``condition:`` label-gate.
Values are coerced to strings — label storage is always
string-valued, and a YAML author writing
``condition: {integrity: 0}`` (unquoted int) would
otherwise produce a silent runtime mismatch against the
stored ``"0"``. The coercion matches omnigent parity
for label values.
:param raw: The ``condition:`` value from YAML, or
``None`` / absent.
:param policy_name: Enclosing policy name for error
messages.
:returns: Dict mapping key → string value or list of
string values. ``None`` when *raw* is absent OR when
*raw* is an empty dict — both mean "always match."
:raises OmnigentError: On a non-dict value.
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise OmnigentError(
f"policy {policy_name!r}: `condition:` must be a mapping, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
if not raw:
# Empty condition matches everything — equivalent to
# omitting the field. Treated identically by returning
# ``None`` here so downstream label-gate evaluation
# takes the always-match short-circuit. (Earlier
# revisions rejected ``{}`` as a typo guard; the guard
# produced false positives on policies whose author
# intended "match any labels, filter only by ``on:``".)
return None
coerced: dict[str, str | list[str]] = {}
for key, value in raw.items():
if isinstance(value, list):
coerced[str(key)] = [str(v) for v in value]
else:
coerced[str(key)] = str(value)
return coerced
def _parse_writable_labels(
raw: Any,
*,
policy_name: str,
) -> list[str] | None:
"""
Parse a policy's ``set_labels:`` whitelist (list form —
used on PromptPolicy and FunctionPolicy).
:param raw: The ``set_labels:`` list of allowed label
keys (or ``None`` / absent).
:param policy_name: Enclosing policy name for error
messages.
:returns: List of allowed label keys, or ``None`` when
*raw* is absent.
:raises OmnigentError: When *raw* is not a list.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise OmnigentError(
f"policy {policy_name!r}: `set_labels:` must be a list "
f"of label keys, got {type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
return [str(k) for k in raw]
def _parse_function_ref(
raw: Any,
*,
policy_name: str,
) -> FunctionRef:
"""
Parse a ``function:`` YAML value into a :class:`FunctionRef`.
Two accepted shapes:
- Bare string: dotted import path of the evaluator
callable.
- Dict: ``{path: ..., arguments: {...}}`` — path resolves
to a factory called with ``arguments`` kwargs at
workflow start.
:param raw: The raw ``function:`` value from YAML.
:param policy_name: Enclosing policy name for error
messages.
:returns: A populated :class:`FunctionRef`.
:raises OmnigentError: On malformed shape — non-string
path, missing path in dict form, non-dict
``arguments``.
"""
if isinstance(raw, str):
if not raw:
raise OmnigentError(
f"policy {policy_name!r}: `function:` path must be non-empty",
code=ErrorCode.INVALID_INPUT,
)
return FunctionRef(path=raw, arguments=None)
if not isinstance(raw, dict):
raise OmnigentError(
f"policy {policy_name!r}: `function:` must be a dotted-path "
f"string or a dict with {{path, arguments}}, got "
f"{type(raw).__name__}",
code=ErrorCode.INVALID_INPUT,
)
path = raw.get("path")
if not isinstance(path, str) or not path:
raise OmnigentError(
f"policy {policy_name!r}: `function.path` must be a non-empty dotted-path string",
code=ErrorCode.INVALID_INPUT,
)
args = raw.get("arguments")
if args is not None and not isinstance(args, dict):
raise OmnigentError(
f"policy {policy_name!r}: `function.arguments` must be a "
f"mapping (or omitted), got {type(args).__name__}",
code=ErrorCode.INVALID_INPUT,
)
return FunctionRef(path=path, arguments=args)
def _parse_policy_ask_timeout(
raw: Any,
*,
policy_name: str,
) -> int | None:
"""
Parse a per-policy ``ask_timeout:`` override.
``None`` / absent = fall back to the guardrails-level
default. Values ``<= 0`` are rejected (POLICIES.md §13).
:param raw: The ``ask_timeout:`` value from YAML.
:param policy_name: Enclosing policy name for error
messages.
:returns: Integer override in seconds, or ``None`` when
*raw* is absent.
:raises OmnigentError: On non-integer or non-positive
value.
"""
if raw is None:
return None
value = _parse_int_field(raw, f"policy {policy_name!r}: `ask_timeout`")
if value <= 0:
raise OmnigentError(
f"policy {policy_name!r}: `ask_timeout` must be > 0 "
"(use large finite values for long waits)",
code=ErrorCode.INVALID_INPUT,
)
return value
def parse_default_policies(
raw: dict[str, Any] | None,
*,
expand_env: bool = True,
) -> list[PolicySpec]:
"""
Parse the ``policies:`` mapping from the server ``--config``
YAML into a list of :class:`PolicySpec` instances.
The YAML shape is a mapping keyed by policy name — the same grammar
as ``guardrails.policies:`` in an agent spec:
.. code-block:: yaml
policies:
admin__audit_tool_calls:
type: function
function: myorg.policies.audit
admin__deny_pii_output:
type: prompt
on: [response]
action: [allow, deny]
prompt: "Deny if the response contains PII..."
For ``type: function`` policies the ``on:`` field is ignored —
the callable self-selects which phases to act on.
Returns an empty list when *raw* is ``None`` or an empty mapping —
the server starts up with no default policies in that case.
:param raw: The ``policies:`` value from the server config
YAML, e.g. ``{"admin__audit": {"type": "function",
"function": "myorg.policies.audit"}}``. ``None`` when the key
is absent.
:param expand_env: Whether to expand ``${VAR}`` references in any
nested ``llm.connection`` blocks (PromptPolicy LLM overrides).
``True`` for production; ``False`` for validation contexts
where env vars may not be set.
:returns: Ordered list of :class:`PolicySpec` instances ready for
the policy engine. Empty list when *raw* is ``None`` or ``{}``.
:raises OmnigentError: On any malformed policy entry — unknown
type, missing required field, invalid phase selector, etc.
"""
if not raw:
return []
return _parse_policies(raw, expand_env=expand_env) or []
def parse_server_llm(
raw: dict[str, Any] | None,
*,
expand_env: bool = True,
) -> LLMConfig | None:
"""
Parse the ``llm:`` block from the server ``--config`` YAML.
Delegates to :func:`_parse_llm` — same grammar as the agent-level
``llm:`` block. Exposed as a public entry point so the CLI can
call it without reaching into parser internals.
:param raw: The ``llm:`` value from the server config YAML,
e.g. ``{"model": "openai/gpt-4o-mini", "connection": {"api_key": "..."}}``.
``None`` when the key is absent.
:param expand_env: Whether to expand ``${VAR}`` references in
the connection block. ``True`` for production; ``False``
for validation contexts where env vars may not be set.
:returns: A :class:`LLMConfig` or ``None``.
"""
return _parse_llm(raw, expand_env=expand_env)