orchestrata_ultimate.tools.remote_trigger_tool

Reference › orchestrata_ultimate › tools › remote_trigger_tool

RemoteTriggerTool (SPEC-067) sends an outbound HTTP request, such as a webhook or REST call, with pluggable authentication, bounded retry with exponential backoff, timeout handling and response validation. It uses httpx. It is a BaseTool, registered by agents.tool_registry and register_remote_trigger_tool, and exported from the package __init__. tests/tools/test_remote_trigger_tool.py covers it.

The pieces are small classes composed by the tool:

One httpx client is built per call and reused across that call's attempts. Failures are reported as RemoteTriggerError with a RemoteTriggerErrorCode, and become failed ToolResult values.

At a glance

Source src/orchestrata_ultimate/tools/remote_trigger_tool.py
Lines of code 1284
Classes 12
Functions 18
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.tools.base, orchestrata_ultimate.tools.registry
Imported by orchestrata_ultimate.agents.tool_registry, orchestrata_ultimate.tools
Tests that import it tests/tools/test_remote_trigger_tool.py

Class diagram

Class diagram orchestrata_ultimate.tools.remote_trigger_tool

Dependency diagram

Dependency diagram orchestrata_ultimate.tools.remote_trigger_tool

API

Constants

Name Type Value
_DEFAULT_RETRY_STATUSES tuple[int, ...] (429, 500, 502, 503, 504)
_SCHEMA_TYPE_MAP dict[str, Any] {'object': dict, 'array': list, 'string': str, 'integer': int, 'number': (int, …
_PARAMETERS dict[str, Any] {'type': 'object', 'properties': {'url': {'type': 'string', 'description': 'Tar…

Classes

RemoteTriggerErrorCode

Decorators: traces(spec='067', story='US-067-001', reqs=['REQ-067-023']) · Line 110

String error codes for the RemoteTriggerTool pipeline (spec.md ERR-00N).

RemoteTriggerError

Bases: Exception · Decorators: traces(spec='067', story='US-067-001', reqs=['REQ-067-023']) · Line 134

Raised for any spec.md ERR-00N condition; renders as a failed ToolResult.

Method Signature Summary
__init__ __init__(code: str, message: str) -> None
to_result to_result(tool_name: str) -> ToolResult

AuthConfig (dataclass)

Decorators: traces(spec='067', story='US-067-002', reqs=['REQ-067-002', 'REQ-067-003']), dataclass(slots=True, frozen=True) · Line 157

Resolved authentication configuration (spec.md AuthConfig, all variants).

Attribute Type Default
kind str
key str ''
value str ''
value_env str ''
location str 'header'
token str ''
token_env str ''
username str ''
password str ''
password_env str ''
client_id str ''
client_secret str ''
client_secret_env str ''
token_url str ''
scopes tuple[str, ...] ()
grant_type str 'client_credentials'
secret str ''
secret_env str ''
algorithm str 'sha256'
header_name str 'X-Signature'
Method Signature Summary
from_dict classmethod from_dict(raw: dict[str, Any]) -> AuthConfig

RetryConfig (dataclass)

Decorators: traces(spec='067', story='US-067-003', reqs=['REQ-067-008', 'REQ-067-009']), dataclass(slots=True, frozen=True) · Line 221

Retry behaviour (spec.md RetryConfig).

Attribute Type Default
enabled bool True
max_attempts int 3
initial_delay float 1.0
max_delay float 60.0
backoff_factor float 2.0
retry_on_status tuple[int, ...] _DEFAULT_RETRY_STATUSES
retry_on_timeout bool True
jitter bool False

ValidationConfig (dataclass)

Decorators: traces(spec='067', story='US-067-004', reqs=['REQ-067-011', 'REQ-067-012', 'REQ-067-013', 'REQ-067-014']), dataclass(slots=True, frozen=True) · Line 240

Response validation rules (spec.md ValidationConfig).

Attribute Type Default
expected_status tuple[int, ...] ()
min_response_time float | None None
max_response_time float | None None
schema dict[str, Any] | None None
signature_secret str ''
signature_secret_env str ''
signature_header str 'X-Signature'
signature_algorithm str 'sha256'

TriggerRequest (dataclass)

Decorators: traces(spec='067', story='US-067-001', reqs=['REQ-067-001', 'REQ-067-007']), dataclass(slots=True, frozen=True) · Line 258

A validated remote-trigger request (spec.md TriggerRequest).

Attribute Type Default
url str
method str 'POST'
headers dict[str, str] field(default_factory=dict)
body Any None
params dict[str, str] field(default_factory=dict)
auth AuthConfig | None None
timeout int | float 30
retry RetryConfig field(default_factory=RetryConfig)
validation ValidationConfig field(default_factory=ValidationConfig)
verify_ssl bool True
ca_bundle_path str | None None
connection_pool_size int 10
allow_internal bool False
extract_fields tuple[str, ...] ()
env dict[str, str] | None None
Method Signature Summary
from_arguments classmethod from_arguments(arguments: dict[str, Any], env: Mapping[str, str] | None = None) -> TriggerRequest Build a :class:TriggerRequest from raw tool-call arguments.

TriggerResponse (dataclass)

Decorators: traces(spec='067', story='US-067-001', reqs=['REQ-067-001', 'REQ-067-018']), dataclass(slots=True) · Line 386

Result of a trigger execution (spec.md TriggerResponse).

Attribute Type Default
success bool
status_code int
status_text str ''
timestamp str ''
elapsed_ms int 0
headers dict[str, str] field(default_factory=dict)
body Any None
extracted dict[str, Any] field(default_factory=dict)
validation_passed bool True
validation_errors list[str] field(default_factory=list)
retry_count int 0
error str | None None
Method Signature Summary
to_json to_json() -> str

SSRFGuard

Decorators: traces(spec='067', story='US-067-005', reqs=['REQ-067-020']) · Line 658

Blocks outbound requests to loopback/private/link-local/metadata targets.

Method Signature Summary
check check(url: str, allow_internal: bool, env: Mapping[str, str] | None = None) -> None

AuthManager

Decorators: traces(spec='067', story='US-067-002', reqs=['REQ-067-002', 'REQ-067-003', 'REQ-067-004', 'REQ-067-005', 'REQ-067-006']) · Line 703

Applies the configured :class:AuthConfig as request headers/params.

Method Signature Summary
apply async apply(request: TriggerRequest, client: httpx.AsyncClient, body_bytes: bytes) -> tuple[dict[str, str], dict[str, str]] Return (extra_headers, extra_params) for request's auth.
_fetch_oauth2_token async _fetch_oauth2_token(auth: AuthConfig, client: httpx.AsyncClient, env: Mapping[str, str] | None) -> str

RetryExecutor

Decorators: traces(spec='067', story='US-067-003', reqs=['REQ-067-008', 'REQ-067-009', 'REQ-067-010']) · Line 823

Bounded exponential-backoff retry over an async request callable.

Method Signature Summary
run async run(do_request: Callable[[], Awaitable[httpx.Response]], retry: RetryConfig, sleep: Callable[[float], Awaitable[None]]) -> tuple[httpx.Response | None, int] Returns (response_or_None, retry_count).

ResponseValidator

Decorators: traces(spec='067', story='US-067-004', reqs=['REQ-067-011', 'REQ-067-012', 'REQ-067-013', 'REQ-067-014']) · Line 874

Validates a response against a :class:ValidationConfig (FR-005).

Method Signature Summary
validate validate(response: httpx.Response, elapsed_ms: int, cfg: ValidationConfig, env: Mapping[str, str] | None) -> tuple[bool, list[str]]

RemoteTriggerTool

Bases: BaseTool · Decorators: traces(spec='067', story='US-067-001', reqs=['REQ-067-001', 'REQ-067-007', 'REQ-067-015', 'REQ-067-016', 'REQ-067-017',… · Line 1037

The registrable BaseTool (Command) callers/LLMs invoke.

Method Signature Summary
__init__ __init__(metadata: ToolMetadata | None = None) -> None
metadata property metadata() -> ToolMetadata
execute async execute(arguments: dict[str, Any]) -> ToolResult Run a trigger from raw tool-call arguments (FR-001).
execute_simple async execute_simple(url: str, method: str = 'POST', headers: dict[str, str] | None = None, body: Any = None, auth: dict[str, Any] | None = None, timeout: int = 30, env: Mapping[str, str] | None = None) -> ToolResult Simplified interface for basic triggers (spec.md API Contract).
_execute_request async _execute_request(request: TriggerRequest) -> ToolResult
verify_signature async verify_signature(payload: bytes, signature: str, secret: str, algorithm: str = 'sha256') -> bool Verify a webhook HMAC signature (spec.md API Contract, FR-011).
parse_response async parse_response(response: httpx.Response, validation: ValidationConfig | None = None) -> dict[str, Any] Parse (and optionally validate) an httpx.Response (FR-009).
_run async _run(request: TriggerRequest) -> TriggerResponse Execute request end-to-end: SSRF guard -> auth -> retry -> validate.

Functions

Function Signature Summary
_resolve_secret traces _resolve_secret(literal: str, env_name: str, env: Mapping[str, str] | None, field_label: str) -> str Resolve a secret from a literal value or an *_env variable name.
_encode_body traces _encode_body(body: Any) -> bytes Render a request body to bytes (for HMAC signing).
_hmac_digest traces _hmac_digest(secret: str, payload: bytes, algorithm: str) -> str
_verify_hmac traces _verify_hmac(payload: bytes, signature: str, secret: str, algorithm: str) -> bool Constant-time HMAC comparison; tolerates a "sha256=<hex>" prefix.
_sanitize_url traces _sanitize_url(url: str) -> str Strip query string (may carry an API key) before logging a URL.
_sanitize_headers traces _sanitize_headers(headers: dict[str, str], sensitive: frozenset[str] = _DEFAULT_SENSITIVE_HEADERS) -> dict[str, str]
_now_iso _now_iso() -> str
_status_error_message traces _status_error_message(status: int, url: str, retry_count: int, retry_cfg: RetryConfig) -> str Map a non-2xx status to a spec.md ERR-00N message.
_parse_body traces _parse_body(response: httpx.Response) -> Any Parse JSON responses; fall back to raw text (FR-009).
_extract_fields traces _extract_fields(body: Any, fields: tuple[str, ...]) -> dict[str, Any] Pull dotted-path fields out of a JSON object body (FR-009).
_type_matches traces _type_matches(instance: Any, expected_type: Any) -> bool
_validate_schema traces _validate_schema(instance: Any, schema: dict[str, Any], path: str = '$') -> list[str] Validate instance against a JSON-Schema subset: type/required/properties/items.
_is_blocked_address traces _is_blocked_address(ip_str: str) -> bool True if ip_str is loopback/private/link-local/reserved (or unparsable).
_allow_private_from_env traces _allow_private_from_env(env: Mapping[str, str] | None) -> bool
_parse_retry_after traces _parse_retry_after(value: str | None) -> float | None
_compute_delay traces _compute_delay(attempt: int, retry: RetryConfig, retry_after: float | None) -> float
_build_metadata _build_metadata() -> ToolMetadata
register_remote_trigger_tool traces register_remote_trigger_tool(registry: ToolRegistry) -> RemoteTriggerTool Build a :class:RemoteTriggerTool and register it into registry.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/tools/remote_trigger_tool.py

"""RemoteTriggerTool — outbound webhook/API trigger with auth + retry/backoff.

Spec: SPEC-067 (RemoteTriggerTool). A :class:`~orchestrata_ultimate.tools.base.BaseTool`
that sends an HTTP request (webhook or REST call) with pluggable authentication,
bounded exponential-backoff retry, timeout handling, and response validation —
built on ``httpx`` (already declared in ``pyproject.toml``; no new dependency).

**MVP scope / forks from spec.md** (reported to the orchestrator, not silently
guessed):

* **No pydantic models.** spec.md's API Contracts sketch ``TriggerRequest`` /
  ``RemoteTriggerConfig`` as pydantic ``BaseModel``\\s; this module uses plain
  ``@dataclass``\\es instead, matching the prevailing style of the other
  SPEC-06x tools (``tools/search.py``, ``tools/worktree_tools.py``) rather than
  introducing a second modelling convention.
* **OAuth2** implements a single client-credentials token fetch per request
  (no token caching/refresh-lifecycle — spec.md's "Manages OAuth2 token
  refresh lifecycle" is a multi-call optimization, out of MVP scope).
* **JSON Schema validation** (FR-005) is a lightweight subset: ``type``,
  ``required``, ``properties``, ``items`` — not full JSON Schema draft-07
  (no ``format``/``pattern``/``enum``/``$ref``; no external ``jsonschema``
  dependency is declared in ``pyproject.toml``, and adding one is out of
  scope for this brief).
* **SSRF protection** (Security Requirements) resolves the target hostname
  once via :func:`socket.getaddrinfo` and blocks loopback/private/link-local/
  reserved addresses and known metadata endpoints — mirroring the existing
  ``agents.tool_registry._is_blocked_address``/``_web_tools_allow_private``
  pattern for ``web_fetch``. It does **not** implement DNS-rebinding
  protection (re-resolving between check and connect) or an allowlist;
  ``allow_internal=True`` (per-request) or the ``REMOTE_TRIGGER_ALLOW_PRIVATE``
  env var (read at call time, never hardcoded — REQ project rule) are the
  sanctioned escape hatches, used by this module's own tests to target a real
  local HTTP server.
* **Connection pooling** (FR-007) is scoped to the lifetime of a single
  ``execute``/``execute_simple`` call: one ``httpx.AsyncClient`` (with
  ``httpx.Limits``) is built per call and its pool is reused across that
  call's own retry attempts. A cross-call persistent client is deferred —
  the stateless ``BaseTool.execute(arguments) -> ToolResult`` contract has no
  lifecycle hook (no ``close()``/``aclose()`` on the Protocol) to tear one
  down safely.
* **Query parameters** (``params``) and **field extraction**
  (``extract_fields``) are MVP additions beyond spec.md's literal
  ``TriggerRequest`` JSON Schema (needed to support ``api_key`` auth placed
  "in": "query", and FR-009's "extract specific fields from responses").
* ``execute_simple``'s ``auth`` parameter is typed ``dict[str, Any] | None``
  (the same raw shape ``TriggerRequest.from_arguments`` parses), not spec.md's
  ``AuthConfig`` object — :class:`AuthConfig` is this module's internal,
  already-resolved representation, not a caller-facing constructor.

Architecture: :class:`RemoteTriggerTool` (the registrable ``BaseTool``)
composes :class:`SSRFGuard`, :class:`AuthManager`, :class:`RetryExecutor`, and
:class:`ResponseValidator` — each a focused, independently testable unit.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import ipaddress
import itertools
import json
import logging
import os
import random
import socket
import time
import urllib.parse
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any

import httpx

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.tools.base import (
    BaseTool,
    ToolCategory,
    ToolMetadata,
    ToolPriority,
    ToolResult,
)
from orchestrata_ultimate.tools.registry import ToolRegistry

logger = logging.getLogger(__name__)

# ── constants ────────────────────────────────────────────────────────────

_ALLOWED_METHODS = frozenset(
    {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
)
_ALLOWED_AUTH_KINDS = frozenset({"api_key", "bearer", "basic", "oauth2", "hmac"})
_ALLOWED_HMAC_ALGORITHMS = frozenset({"sha256", "sha512", "sha1"})
_DEFAULT_RETRY_STATUSES: tuple[int, ...] = (429, 500, 502, 503, 504)
_DEFAULT_SENSITIVE_HEADERS = frozenset({"authorization", "x-api-key"})
_BLOCKED_HOSTNAMES = frozenset(
    {"metadata.google.internal", "metadata", "metadata.internal"}
)
# The fixed IANA link-local address every cloud provider (AWS/GCP/Azure/
# DigitalOcean) uses for its metadata service — an SSRF blocklist constant,
# not deployment infra (link-local is skip-listed by the HARDCODED_IP gate).
_METADATA_IPS = frozenset({"169.254.169.254"})


# ── error codes / exception ─────────────────────────────────────────────


@traces(spec="067", story="US-067-001", reqs=["REQ-067-023"])
class RemoteTriggerErrorCode:
    """String error codes for the RemoteTriggerTool pipeline (spec.md ERR-00N)."""

    INVALID_URL = "invalid_url"
    AUTH_FAILED = "auth_failed"
    FORBIDDEN = "forbidden"
    NOT_FOUND = "not_found"
    RATE_LIMITED = "rate_limited"
    SERVER_ERROR = "server_error"
    TIMEOUT = "timeout"
    CONNECTION_ERROR = "connection_error"
    SSL_ERROR = "ssl_error"
    VALIDATION_FAILED = "validation_failed"
    INVALID_SIGNATURE = "invalid_signature"
    RETRY_EXHAUSTED = "retry_exhausted"
    INVALID_CONFIG = "invalid_config"
    BODY_TOO_LARGE = "body_too_large"
    DNS_FAILURE = "dns_failure"
    INVALID_JSON = "invalid_json"
    CONTENT_TYPE_MISMATCH = "content_type_mismatch"
    SSRF_BLOCKED = "ssrf_blocked"


@traces(spec="067", story="US-067-001", reqs=["REQ-067-023"])
class RemoteTriggerError(Exception):
    """Raised for any spec.md ERR-00N condition; renders as a failed ToolResult."""

    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code
        self.message = message

    @traces(spec="067", reqs=["REQ-067-023"])
    def to_result(self, tool_name: str) -> ToolResult:
        return ToolResult(
            tool_name=tool_name,
            success=False,
            output="",
            error=f"{self.code}: {self.message}",
        )


# ── auth / retry / validation config ────────────────────────────────────


@traces(spec="067", story="US-067-002", reqs=["REQ-067-002", "REQ-067-003"])
@dataclass(slots=True, frozen=True)
class AuthConfig:
    """Resolved authentication configuration (spec.md AuthConfig, all variants).

    A single flat dataclass discriminated by ``kind`` rather than 5 separate
    types (spec.md's ``oneOf`` union) — simpler to build from a raw tool-call
    ``arguments["auth"]`` dict and to pattern-match on in :class:`AuthManager`.
    """

    kind: str
    key: str = ""
    value: str = ""
    value_env: str = ""
    location: str = "header"
    token: str = ""
    token_env: str = ""
    username: str = ""
    password: str = ""
    password_env: str = ""
    client_id: str = ""
    client_secret: str = ""
    client_secret_env: str = ""
    token_url: str = ""
    scopes: tuple[str, ...] = ()
    grant_type: str = "client_credentials"
    secret: str = ""
    secret_env: str = ""
    algorithm: str = "sha256"
    header_name: str = "X-Signature"

    @classmethod
    @traces(spec="067", story="US-067-002", reqs=["REQ-067-002", "REQ-067-003"])
    def from_dict(cls, raw: dict[str, Any]) -> AuthConfig:
        kind = str(raw.get("type", "")).lower()
        if kind not in _ALLOWED_AUTH_KINDS:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.INVALID_CONFIG,
                f"Unknown auth type: {raw.get('type')!r}",
            )
        return cls(
            kind=kind,
            key=str(raw.get("key", "")),
            value=str(raw.get("value", "")),
            value_env=str(raw.get("value_env", "")),
            location=str(raw.get("in", "header")),
            token=str(raw.get("token", "")),
            token_env=str(raw.get("token_env", "")),
            username=str(raw.get("username", "")),
            password=str(raw.get("password", "")),
            password_env=str(raw.get("password_env", "")),
            client_id=str(raw.get("client_id", "")),
            client_secret=str(raw.get("client_secret", "")),
            client_secret_env=str(raw.get("client_secret_env", "")),
            token_url=str(raw.get("token_url", "")),
            scopes=tuple(raw.get("scopes", ()) or ()),
            grant_type=str(raw.get("grant_type", "client_credentials")),
            secret=str(raw.get("secret", "")),
            secret_env=str(raw.get("secret_env", "")),
            algorithm=str(raw.get("algorithm", "sha256")),
            header_name=str(raw.get("header_name", "X-Signature")),
        )


@traces(spec="067", story="US-067-003", reqs=["REQ-067-008", "REQ-067-009"])
@dataclass(slots=True, frozen=True)
class RetryConfig:
    """Retry behaviour (spec.md RetryConfig)."""

    enabled: bool = True
    max_attempts: int = 3
    initial_delay: float = 1.0
    max_delay: float = 60.0
    backoff_factor: float = 2.0
    retry_on_status: tuple[int, ...] = _DEFAULT_RETRY_STATUSES
    retry_on_timeout: bool = True
    jitter: bool = False


@traces(
    spec="067",
    story="US-067-004",
    reqs=["REQ-067-011", "REQ-067-012", "REQ-067-013", "REQ-067-014"],
)
@dataclass(slots=True, frozen=True)
class ValidationConfig:
    """Response validation rules (spec.md ValidationConfig)."""

    expected_status: tuple[int, ...] = ()
    min_response_time: float | None = None
    max_response_time: float | None = None
    schema: dict[str, Any] | None = None
    signature_secret: str = ""
    signature_secret_env: str = ""
    signature_header: str = "X-Signature"
    signature_algorithm: str = "sha256"


# ── request / response ──────────────────────────────────────────────────


@traces(spec="067", story="US-067-001", reqs=["REQ-067-001", "REQ-067-007"])
@dataclass(slots=True, frozen=True)
class TriggerRequest:
    """A validated remote-trigger request (spec.md TriggerRequest)."""

    url: str
    method: str = "POST"
    headers: dict[str, str] = field(default_factory=dict)
    body: Any = None
    params: dict[str, str] = field(default_factory=dict)
    auth: AuthConfig | None = None
    # int per the public tool-call JSON schema (spec.md: integer, 1-300s);
    # widened to accept float here too so callers constructing a
    # TriggerRequest directly (bypassing from_arguments's int() cast) can use
    # sub-second timeouts — httpx.Timeout accepts a float natively.
    timeout: int | float = 30
    retry: RetryConfig = field(default_factory=RetryConfig)
    validation: ValidationConfig = field(default_factory=ValidationConfig)
    verify_ssl: bool = True
    ca_bundle_path: str | None = None
    connection_pool_size: int = 10
    allow_internal: bool = False
    extract_fields: tuple[str, ...] = ()
    env: dict[str, str] | None = None

    @classmethod
    @traces(spec="067", story="FR-003", reqs=["REQ-067-007", "REQ-067-023"])
    def from_arguments(
        cls, arguments: dict[str, Any], env: Mapping[str, str] | None = None
    ) -> TriggerRequest:
        """Build a :class:`TriggerRequest` from raw tool-call ``arguments``.

        Raises:
            RemoteTriggerError: ``INVALID_URL`` (ERR-001) for a missing/
                malformed URL, ``INVALID_CONFIG`` (ERR-013) for any other
                malformed field.
        """
        url = str(arguments.get("url") or "").strip()
        parsed = urllib.parse.urlsplit(url)
        if (
            not url
            or parsed.scheme.lower() not in ("http", "https")
            or not parsed.hostname
        ):
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.INVALID_URL,
                f"Invalid or malformed URL: {url}",
            )
        method = str(arguments.get("method", "POST")).upper()
        if method not in _ALLOWED_METHODS:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.INVALID_CONFIG,
                f"Unsupported HTTP method: {method}",
            )
        auth_raw = arguments.get("auth")
        auth = AuthConfig.from_dict(auth_raw) if auth_raw else None

        retry_raw = arguments.get("retry") or {}
        validation_raw = arguments.get("validation") or {}
        try:
            retry = RetryConfig(
                enabled=bool(retry_raw.get("enabled", True)),
                max_attempts=int(retry_raw.get("max_attempts", 3)),
                initial_delay=float(retry_raw.get("initial_delay", 1.0)),
                max_delay=float(retry_raw.get("max_delay", 60.0)),
                backoff_factor=float(retry_raw.get("backoff_factor", 2.0)),
                retry_on_status=tuple(
                    int(s)
                    for s in retry_raw.get("retry_on_status", _DEFAULT_RETRY_STATUSES)
                ),
                retry_on_timeout=bool(retry_raw.get("retry_on_timeout", True)),
                jitter=bool(retry_raw.get("jitter", False)),
            )
            validation = ValidationConfig(
                expected_status=tuple(
                    int(s) for s in validation_raw.get("expected_status", ())
                ),
                min_response_time=(
                    float(validation_raw["min_response_time"])
                    if validation_raw.get("min_response_time") is not None
                    else None
                ),
                max_response_time=(
                    float(validation_raw["max_response_time"])
                    if validation_raw.get("max_response_time") is not None
                    else None
                ),
                schema=validation_raw.get("schema"),
                signature_secret=str(validation_raw.get("signature_secret", "")),
                signature_secret_env=str(
                    validation_raw.get("signature_secret_env", "")
                ),
                signature_header=str(
                    validation_raw.get("signature_header", "X-Signature")
                ),
                signature_algorithm=str(
                    validation_raw.get("signature_algorithm", "sha256")
                ),
            )
            timeout = int(arguments.get("timeout", 30))
            pool_size = int(arguments.get("connection_pool_size", 10))
        except (TypeError, ValueError) as exc:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.INVALID_CONFIG,
                f"Configuration error: {exc}",
            ) from exc

        return cls(
            url=url,
            method=method,
            headers={
                str(k): str(v) for k, v in (arguments.get("headers") or {}).items()
            },
            body=arguments.get("body"),
            params={str(k): str(v) for k, v in (arguments.get("params") or {}).items()},
            auth=auth,
            timeout=timeout,
            retry=retry,
            validation=validation,
            verify_ssl=bool(arguments.get("verify_ssl", True)),
            ca_bundle_path=arguments.get("ca_bundle_path"),
            connection_pool_size=pool_size,
            allow_internal=bool(arguments.get("allow_internal", False)),
            extract_fields=tuple(arguments.get("extract_fields", ()) or ()),
            env=dict(env) if env is not None else None,
        )


@traces(spec="067", story="US-067-001", reqs=["REQ-067-001", "REQ-067-018"])
@dataclass(slots=True)
class TriggerResponse:
    """Result of a trigger execution (spec.md TriggerResponse)."""

    success: bool
    status_code: int
    status_text: str = ""
    timestamp: str = ""
    elapsed_ms: int = 0
    headers: dict[str, str] = field(default_factory=dict)
    body: Any = None
    extracted: dict[str, Any] = field(default_factory=dict)
    validation_passed: bool = True
    validation_errors: list[str] = field(default_factory=list)
    retry_count: int = 0
    error: str | None = None

    @traces(spec="067", story="US-067-001", reqs=["REQ-067-001", "REQ-067-018"])
    def to_json(self) -> str:
        return json.dumps(
            {
                "success": self.success,
                "status_code": self.status_code,
                "status_text": self.status_text,
                "timestamp": self.timestamp,
                "elapsed_ms": self.elapsed_ms,
                "headers": self.headers,
                "body": self.body,
                "extracted": self.extracted,
                "validation_passed": self.validation_passed,
                "validation_errors": self.validation_errors,
                "retry_count": self.retry_count,
                "error": self.error,
            },
            default=str,
        )


# ── helpers: env / body / logging / status messages ────────────────────


@traces(spec="067", reqs=["REQ-067-003", "REQ-067-004", "REQ-067-006"])
def _resolve_secret(
    literal: str, env_name: str, env: Mapping[str, str] | None, field_label: str
) -> str:
    """Resolve a secret from a literal value or an ``*_env`` variable name.

    Reads the environment at call time via ``os.environ.get`` (never a bare
    subscript) — an optional ``env`` mapping lets callers (tests) override it
    without mutating ``os.environ``.
    """
    if env_name:
        source = os.environ if env is None else env
        value = source.get(env_name, "")
        if not value:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.AUTH_FAILED,
                f"Environment variable {env_name!r} for {field_label} is not set",
            )
        return value
    if literal:
        return literal
    raise RemoteTriggerError(
        RemoteTriggerErrorCode.INVALID_CONFIG,
        f"Missing {field_label}: provide a literal value or an *_env variable name",
    )


@traces(spec="067", story="FR-002", reqs=["REQ-067-006"])
def _encode_body(body: Any) -> bytes:
    """Render a request body to bytes (for HMAC signing)."""
    if body is None:
        return b""
    if isinstance(body, (dict, list)):
        return json.dumps(body, sort_keys=True).encode()
    if isinstance(body, str):
        return body.encode()
    return str(body).encode()


@traces(spec="067", story="FR-002", reqs=["REQ-067-006"])
def _hmac_digest(secret: str, payload: bytes, algorithm: str) -> str:
    algorithm = algorithm.lower()
    if algorithm not in _ALLOWED_HMAC_ALGORITHMS:
        raise RemoteTriggerError(
            RemoteTriggerErrorCode.INVALID_CONFIG,
            f"Unsupported HMAC algorithm: {algorithm}",
        )
    digestmod = getattr(hashlib, algorithm)
    return hmac.new(secret.encode(), payload, digestmod).hexdigest()


@traces(spec="067", story="FR-005", reqs=["REQ-067-014", "REQ-067-022"])
def _verify_hmac(payload: bytes, signature: str, secret: str, algorithm: str) -> bool:
    """Constant-time HMAC comparison; tolerates a ``"sha256=<hex>"`` prefix."""
    try:
        expected = _hmac_digest(secret, payload, algorithm)
    except RemoteTriggerError:
        return False
    sig = (signature or "").strip()
    if "=" in sig:
        _, _, sig = sig.partition("=")
    try:
        return hmac.compare_digest(expected, sig)
    except TypeError:
        return False


@traces(spec="067", story="FR-010", reqs=["REQ-067-019"])
def _sanitize_url(url: str) -> str:
    """Strip query string (may carry an API key) before logging a URL."""
    parsed = urllib.parse.urlsplit(url)
    return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))


@traces(spec="067", story="FR-010", reqs=["REQ-067-019"])
def _sanitize_headers(
    headers: dict[str, str], sensitive: frozenset[str] = _DEFAULT_SENSITIVE_HEADERS
) -> dict[str, str]:
    return {
        k: ("***REDACTED***" if k.lower() in sensitive else v)
        for k, v in headers.items()
    }


def _now_iso() -> str:
    return datetime.now(UTC).isoformat()


@traces(spec="067", reqs=["REQ-067-023"])
def _status_error_message(
    status: int, url: str, retry_count: int, retry_cfg: RetryConfig
) -> str:
    """Map a non-2xx status to a spec.md ERR-00N message."""
    if (
        retry_cfg.enabled
        and retry_cfg.max_attempts > 1
        and status in retry_cfg.retry_on_status
        and retry_count >= retry_cfg.max_attempts - 1
    ):
        return f"Max retry attempts ({retry_cfg.max_attempts}) exceeded"
    if status == 401:
        return "Authentication failed: invalid or missing credentials"
    if status == 403:
        return f"Access forbidden to {url}"
    if status == 404:
        return f"Endpoint not found: {url}"
    if status == 413:
        return "Request body exceeds maximum allowed size"
    if status == 429:
        return "Rate limit exceeded"
    if 500 <= status < 600:
        return f"Server error: {status}"
    return f"Unexpected status code: {status}"


@traces(spec="067", story="FR-009", reqs=["REQ-067-018"])
def _parse_body(response: httpx.Response) -> Any:
    """Parse JSON responses; fall back to raw text (FR-009)."""
    content_type = response.headers.get("content-type", "")
    if "json" in content_type.lower():
        try:
            return response.json()
        except ValueError:
            return response.text
    return response.text


@traces(spec="067", story="FR-009", reqs=["REQ-067-018"])
def _extract_fields(body: Any, fields: tuple[str, ...]) -> dict[str, Any]:
    """Pull dotted-path ``fields`` out of a JSON object ``body`` (FR-009)."""
    if not fields or not isinstance(body, dict):
        return {}
    result: dict[str, Any] = {}
    for path in fields:
        node: Any = body
        for part in path.split("."):
            if isinstance(node, dict) and part in node:
                node = node[part]
            else:
                node = None
                break
        result[path] = node
    return result


# ── lightweight JSON-schema subset validator (FR-005) ───────────────────

_SCHEMA_TYPE_MAP: dict[str, Any] = {
    "object": dict,
    "array": list,
    "string": str,
    "integer": int,
    "number": (int, float),
    "boolean": bool,
    "null": type(None),
}


@traces(spec="067", story="FR-005", reqs=["REQ-067-013"])
def _type_matches(instance: Any, expected_type: Any) -> bool:
    types = expected_type if isinstance(expected_type, list) else [expected_type]
    for t in types:
        py_type = _SCHEMA_TYPE_MAP.get(t)
        if py_type is None:
            return True  # unrecognized type keyword: don't fail on it
        if isinstance(instance, bool):
            if t == "boolean":
                return True
            continue  # bool is an int subclass; don't let it satisfy number/integer
        if isinstance(instance, py_type):
            return True
    return False


@traces(spec="067", story="FR-005", reqs=["REQ-067-013"])
def _validate_schema(
    instance: Any, schema: dict[str, Any], path: str = "$"
) -> list[str]:
    """Validate ``instance`` against a JSON-Schema *subset*: type/required/properties/items."""
    errors: list[str] = []
    expected_type = schema.get("type")
    if expected_type is not None and not _type_matches(instance, expected_type):
        errors.append(
            f"{path}: expected type {expected_type!r}, got {type(instance).__name__}"
        )
        return errors
    if isinstance(instance, dict):
        for required_key in schema.get("required", ()):
            if required_key not in instance:
                errors.append(f"{path}: missing required property {required_key!r}")
        for key, sub_schema in schema.get("properties", {}).items():
            if key in instance:
                errors.extend(
                    _validate_schema(instance[key], sub_schema, f"{path}.{key}")
                )
    elif isinstance(instance, list):
        items_schema = schema.get("items")
        if items_schema:
            errors.extend(
                itertools.chain.from_iterable(
                    _validate_schema(item, items_schema, f"{path}[{i}]")
                    for i, item in enumerate(instance)
                )
            )
    return errors


# ── SSRF guard ───────────────────────────────────────────────────────────


@traces(spec="067", reqs=["REQ-067-020"])
def _is_blocked_address(ip_str: str) -> bool:
    """True if ``ip_str`` is loopback/private/link-local/reserved (or unparsable)."""
    try:
        addr = ipaddress.ip_address(ip_str)
    except ValueError:
        return True
    return addr.is_loopback or addr.is_private or addr.is_link_local or addr.is_reserved


@traces(spec="067", reqs=["REQ-067-020"])
def _allow_private_from_env(env: Mapping[str, str] | None) -> bool:
    source = os.environ if env is None else env
    return source.get("REMOTE_TRIGGER_ALLOW_PRIVATE", "").strip().lower() in (
        "1",
        "true",
        "yes",
        "on",
    )


@traces(spec="067", story="US-067-005", reqs=["REQ-067-020"])
class SSRFGuard:
    """Blocks outbound requests to loopback/private/link-local/metadata targets.

    See the module docstring's "SSRF protection" fork note for MVP scope
    (single-resolution check, no DNS-rebinding protection/allowlist).
    """

    @traces(spec="067", reqs=["REQ-067-020"])
    def check(
        self, url: str, allow_internal: bool, env: Mapping[str, str] | None = None
    ) -> None:
        if allow_internal or _allow_private_from_env(env):
            return
        parsed = urllib.parse.urlsplit(url)
        hostname = (parsed.hostname or "").lower()
        if hostname in _BLOCKED_HOSTNAMES:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.SSRF_BLOCKED,
                f"Refused: {hostname} is a blocked hostname",
            )
        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        try:
            infos = socket.getaddrinfo(hostname, port)
        except OSError as exc:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.DNS_FAILURE,
                f"Failed to resolve DNS for {hostname}",
            ) from exc
        for info in infos:
            ip_str = str(info[4][0])
            if ip_str in _METADATA_IPS or _is_blocked_address(ip_str):
                raise RemoteTriggerError(
                    RemoteTriggerErrorCode.SSRF_BLOCKED,
                    f"Refused: {hostname} resolves to a disallowed address ({ip_str})",
                )


# ── authentication manager ──────────────────────────────────────────────


@traces(
    spec="067",
    story="US-067-002",
    reqs=["REQ-067-002", "REQ-067-003", "REQ-067-004", "REQ-067-005", "REQ-067-006"],
)
class AuthManager:
    """Applies the configured :class:`AuthConfig` as request headers/params."""

    @traces(
        spec="067",
        story="FR-002",
        reqs=[
            "REQ-067-002",
            "REQ-067-003",
            "REQ-067-004",
            "REQ-067-005",
            "REQ-067-006",
        ],
    )
    async def apply(
        self, request: TriggerRequest, client: httpx.AsyncClient, body_bytes: bytes
    ) -> tuple[dict[str, str], dict[str, str]]:
        """Return ``(extra_headers, extra_params)`` for ``request``'s auth."""
        auth = request.auth
        if auth is None:
            return {}, {}
        env = request.env
        headers: dict[str, str] = {}
        params: dict[str, str] = {}
        if auth.kind == "api_key":
            value = _resolve_secret(auth.value, auth.value_env, env, "api_key value")
            if auth.location == "query":
                params[auth.key] = value
            else:
                headers[auth.key] = value
        elif auth.kind == "bearer":
            token = _resolve_secret(auth.token, auth.token_env, env, "bearer token")
            headers["Authorization"] = f"Bearer {token}"
        elif auth.kind == "basic":
            password = (
                _resolve_secret(
                    auth.password, auth.password_env, env, "basic auth password"
                )
                if (auth.password or auth.password_env)
                else ""
            )
            raw = f"{auth.username}:{password}".encode()
            headers["Authorization"] = f"Basic {base64.b64encode(raw).decode()}"
        elif auth.kind == "oauth2":
            token = await self._fetch_oauth2_token(auth, client, env)
            headers["Authorization"] = f"Bearer {token}"
        elif auth.kind == "hmac":
            secret = _resolve_secret(auth.secret, auth.secret_env, env, "hmac secret")
            headers[auth.header_name] = _hmac_digest(secret, body_bytes, auth.algorithm)
        return headers, params

    @traces(spec="067", story="FR-002", reqs=["REQ-067-005"])
    async def _fetch_oauth2_token(
        self, auth: AuthConfig, client: httpx.AsyncClient, env: Mapping[str, str] | None
    ) -> str:
        secret = (
            _resolve_secret(
                auth.client_secret, auth.client_secret_env, env, "oauth2 client secret"
            )
            if (auth.client_secret or auth.client_secret_env)
            else ""
        )
        try:
            resp = await client.post(
                auth.token_url,
                data={
                    "grant_type": auth.grant_type or "client_credentials",
                    "client_id": auth.client_id,
                    "client_secret": secret,
                    "scope": " ".join(auth.scopes),
                },
            )
        except httpx.HTTPError as exc:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.AUTH_FAILED,
                f"OAuth2 token request failed: {exc}",
            ) from exc
        if resp.status_code >= 400:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.AUTH_FAILED,
                f"OAuth2 token endpoint returned {resp.status_code}",
            )
        try:
            token = resp.json()["access_token"]
        except (ValueError, KeyError) as exc:
            raise RemoteTriggerError(
                RemoteTriggerErrorCode.AUTH_FAILED,
                "OAuth2 token response missing 'access_token'",
            ) from exc
        return str(token)


# ── retry executor ───────────────────────────────────────────────────────


@traces(spec="067", story="FR-004", reqs=["REQ-067-010"])
def _parse_retry_after(value: str | None) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except ValueError:
        return None  # HTTP-date form not supported (MVP scope)


@traces(spec="067", story="FR-004", reqs=["REQ-067-008", "REQ-067-010"])
def _compute_delay(
    attempt: int, retry: RetryConfig, retry_after: float | None
) -> float:
    if retry_after is not None:
        return max(0.0, min(retry_after, retry.max_delay))
    delay = min(retry.initial_delay * (retry.backoff_factor**attempt), retry.max_delay)
    if retry.jitter:
        delay *= 0.5 + random.random() * 0.5
    return delay


@traces(
    spec="067", story="US-067-003", reqs=["REQ-067-008", "REQ-067-009", "REQ-067-010"]
)
class RetryExecutor:
    """Bounded exponential-backoff retry over an async request callable."""

    @traces(
        spec="067", story="FR-004", reqs=["REQ-067-008", "REQ-067-009", "REQ-067-010"]
    )
    async def run(
        self,
        do_request: Callable[[], Awaitable[httpx.Response]],
        retry: RetryConfig,
        sleep: Callable[[float], Awaitable[None]],
    ) -> tuple[httpx.Response | None, int]:
        """Returns ``(response_or_None, retry_count)``.

        ``response`` is ``None`` only when every attempt timed out (the
        caller renders the ERR-007 timeout message).
        """
        attempt = 0
        while True:
            try:
                response = await do_request()
            except httpx.TimeoutException:
                if (
                    retry.enabled
                    and retry.retry_on_timeout
                    and attempt < retry.max_attempts - 1
                ):
                    await sleep(_compute_delay(attempt, retry, None))
                    attempt += 1
                    continue
                return None, attempt
            if (
                retry.enabled
                and response.status_code in retry.retry_on_status
                and attempt < retry.max_attempts - 1
            ):
                retry_after = _parse_retry_after(response.headers.get("Retry-After"))
                await sleep(_compute_delay(attempt, retry, retry_after))
                attempt += 1
                continue
            return response, attempt


# ── response validator ──────────────────────────────────────────────────


@traces(
    spec="067",
    story="US-067-004",
    reqs=["REQ-067-011", "REQ-067-012", "REQ-067-013", "REQ-067-014"],
)
class ResponseValidator:
    """Validates a response against a :class:`ValidationConfig` (FR-005)."""

    @traces(
        spec="067",
        story="FR-005",
        reqs=["REQ-067-011", "REQ-067-012", "REQ-067-013", "REQ-067-014"],
    )
    def validate(
        self,
        response: httpx.Response,
        elapsed_ms: int,
        cfg: ValidationConfig,
        env: Mapping[str, str] | None,
    ) -> tuple[bool, list[str]]:
        errors: list[str] = []
        if cfg.expected_status and response.status_code not in cfg.expected_status:
            errors.append(
                f"Unexpected status code {response.status_code}; "
                f"expected one of {list(cfg.expected_status)}"
            )
        # No implicit "must be 2xx" check here when expected_status is unset:
        # RemoteTriggerTool._run already derives success from
        # response.status_code < 400 and, on failure, renders the specific
        # ERR-00N wording (401/403/404/429/5xx/retry-exhausted) via
        # _status_error_message — duplicating a generic "non-success status"
        # message here would shadow that more useful text.

        elapsed_s = elapsed_ms / 1000.0
        if cfg.min_response_time is not None and elapsed_s < cfg.min_response_time:
            errors.append(
                f"Response time {elapsed_s:.3f}s below minimum {cfg.min_response_time}s"
            )
        if cfg.max_response_time is not None and elapsed_s > cfg.max_response_time:
            errors.append(
                f"Response time {elapsed_s:.3f}s exceeds maximum {cfg.max_response_time}s"
            )

        if cfg.schema is not None:
            errors.extend(_validate_schema(_parse_body(response), cfg.schema))

        if cfg.signature_secret or cfg.signature_secret_env:
            try:
                secret = _resolve_secret(
                    cfg.signature_secret,
                    cfg.signature_secret_env,
                    env,
                    "validation signature secret",
                )
            except RemoteTriggerError as exc:
                errors.append(str(exc))
                secret = ""
            provided = response.headers.get(cfg.signature_header, "")
            if (
                not secret
                or not provided
                or not _verify_hmac(
                    response.content, provided, secret, cfg.signature_algorithm
                )
            ):
                errors.append("Webhook signature verification failed")

        return (len(errors) == 0, errors)


# ── the tool itself ──────────────────────────────────────────────────────

_PARAMETERS: dict[str, Any] = {
    "type": "object",
    "properties": {
        "url": {
            "type": "string",
            "description": "Target webhook or API endpoint URL.",
        },
        "method": {
            "type": "string",
            "enum": sorted(_ALLOWED_METHODS),
            "default": "POST",
        },
        "headers": {"type": "object", "additionalProperties": {"type": "string"}},
        "body": {"description": "Request body (JSON object/array, or a raw string)."},
        "params": {
            "type": "object",
            "additionalProperties": {"type": "string"},
            "description": "Query parameters (also where 'api_key' auth 'in':'query' lands).",
        },
        "auth": {
            "type": "object",
            "description": (
                "Authentication config: "
                "{type: api_key|bearer|basic|oauth2|hmac, ...type-specific fields}."
            ),
        },
        "timeout": {"type": "integer", "minimum": 1, "maximum": 300, "default": 30},
        "retry": {
            "type": "object",
            "description": (
                "enabled/max_attempts/initial_delay/max_delay/backoff_factor/"
                "retry_on_status/retry_on_timeout/jitter."
            ),
        },
        "validation": {
            "type": "object",
            "description": (
                "expected_status/min_response_time/max_response_time/schema/"
                "signature_secret/signature_secret_env/signature_header/"
                "signature_algorithm."
            ),
        },
        "verify_ssl": {"type": "boolean", "default": True},
        "ca_bundle_path": {"type": "string"},
        "connection_pool_size": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 10,
        },
        "allow_internal": {
            "type": "boolean",
            "default": False,
            "description": "Bypass the SSRF guard for a trusted internal/loopback target.",
        },
        "extract_fields": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Dotted paths to extract from a JSON response body.",
        },
    },
    "required": ["url"],
}


def _build_metadata() -> ToolMetadata:
    return ToolMetadata(
        name="remote_trigger",
        description=(
            "Trigger remote webhooks or API calls with authentication, "
            "retry/backoff, timeout handling, and response validation."
        ),
        category=ToolCategory.WEB,
        parameters=_PARAMETERS,
        priority=ToolPriority.P1,
        requires_approval=True,
        governance_article="tool:execute",
        tags=("webhook", "http", "api", "trigger"),
    )


@traces(
    spec="067",
    story="US-067-001",
    reqs=[
        "REQ-067-001",
        "REQ-067-007",
        "REQ-067-015",
        "REQ-067-016",
        "REQ-067-017",
        "REQ-067-018",
        "REQ-067-019",
        "REQ-067-021",
        "REQ-067-022",
    ],
)
class RemoteTriggerTool(BaseTool):
    """The registrable ``BaseTool`` (Command) callers/LLMs invoke.

    Composes :class:`SSRFGuard`, :class:`AuthManager`, :class:`RetryExecutor`,
    and :class:`ResponseValidator` — one focused unit per FR area.
    """

    def __init__(self, metadata: ToolMetadata | None = None) -> None:
        self._metadata = metadata or _build_metadata()
        self._ssrf = SSRFGuard()
        self._auth = AuthManager()
        self._retry = RetryExecutor()
        self._validator = ResponseValidator()

    @property
    def metadata(self) -> ToolMetadata:
        return self._metadata

    @traces(spec="067", story="US-067-001", reqs=["REQ-067-001"])
    async def execute(self, arguments: dict[str, Any]) -> ToolResult:
        """Run a trigger from raw tool-call ``arguments`` (FR-001). Never raises."""
        try:
            request = TriggerRequest.from_arguments(arguments)
        except RemoteTriggerError as exc:
            return exc.to_result(self.metadata.name)
        return await self._execute_request(request)

    @traces(spec="067", story="US-067-001", reqs=["REQ-067-021"])
    async def execute_simple(
        self,
        url: str,
        method: str = "POST",
        headers: dict[str, str] | None = None,
        body: Any = None,
        auth: dict[str, Any] | None = None,
        timeout: int = 30,
        env: Mapping[str, str] | None = None,
    ) -> ToolResult:
        """Simplified interface for basic triggers (spec.md API Contract)."""
        arguments: dict[str, Any] = {
            "url": url,
            "method": method,
            "headers": headers or {},
            "body": body,
            "timeout": timeout,
        }
        if auth is not None:
            arguments["auth"] = auth
        try:
            request = TriggerRequest.from_arguments(arguments, env=env)
        except RemoteTriggerError as exc:
            return exc.to_result(self.metadata.name)
        return await self._execute_request(request)

    @traces(spec="067", story="FR-001", reqs=["REQ-067-001"])
    async def _execute_request(self, request: TriggerRequest) -> ToolResult:
        try:
            response = await self._run(request)
        except RemoteTriggerError as exc:
            return exc.to_result(self.metadata.name)
        error = None
        if not response.success:
            error = (
                response.error
                or "; ".join(response.validation_errors)
                or "remote trigger failed"
            )
        return ToolResult(
            tool_name=self.metadata.name,
            success=response.success,
            output=response.to_json(),
            error=error,
        )

    @traces(spec="067", story="US-067-004", reqs=["REQ-067-022"])
    async def verify_signature(
        self, payload: bytes, signature: str, secret: str, algorithm: str = "sha256"
    ) -> bool:
        """Verify a webhook HMAC signature (spec.md API Contract, FR-011)."""
        return _verify_hmac(payload, signature, secret, algorithm)

    @traces(spec="067", story="US-067-004", reqs=["REQ-067-018", "REQ-067-011"])
    async def parse_response(
        self, response: httpx.Response, validation: ValidationConfig | None = None
    ) -> dict[str, Any]:
        """Parse (and optionally validate) an ``httpx.Response`` (FR-009)."""
        body = _parse_body(response)
        result: dict[str, Any] = {"status_code": response.status_code, "body": body}
        if validation is not None:
            passed, errors = self._validator.validate(response, 0, validation, None)
            result["validation_passed"] = passed
            result["validation_errors"] = errors
        return result

    @traces(
        spec="067",
        story="US-067-001",
        reqs=[
            "REQ-067-001",
            "REQ-067-015",
            "REQ-067-016",
            "REQ-067-017",
            "REQ-067-020",
        ],
    )
    async def _run(self, request: TriggerRequest) -> TriggerResponse:
        """Execute ``request`` end-to-end: SSRF guard -> auth -> retry -> validate."""
        self._ssrf.check(request.url, request.allow_internal, request.env)

        body_bytes = _encode_body(request.body)
        limits = httpx.Limits(
            max_connections=request.connection_pool_size,
            max_keepalive_connections=request.connection_pool_size,
        )
        verify: bool | str = request.ca_bundle_path or request.verify_ssl
        if not request.verify_ssl:
            logger.warning(
                "remote_trigger: SSL verification disabled for %s",
                _sanitize_url(request.url),
            )
        timeout = httpx.Timeout(float(request.timeout))

        start = time.monotonic()
        async with httpx.AsyncClient(
            limits=limits, verify=verify, timeout=timeout
        ) as client:
            extra_headers, extra_params = await self._auth.apply(
                request, client, body_bytes
            )
            merged_headers = {**request.headers, **extra_headers}
            merged_params = {**request.params, **extra_params}
            logger.debug(
                "remote_trigger: %s %s headers=%s",
                request.method,
                _sanitize_url(request.url),
                _sanitize_headers(merged_headers),
            )

            async def do_request() -> httpx.Response:
                kwargs: dict[str, Any] = {
                    "headers": merged_headers,
                    "params": merged_params,
                }
                if isinstance(request.body, (dict, list)):
                    kwargs["json"] = request.body
                elif isinstance(request.body, str):
                    kwargs["content"] = request.body.encode()
                elif isinstance(request.body, bool | int | float):
                    # spec.md TriggerRequest.body allows a bare scalar; JSON-encode
                    # it so e.g. body=true / body=3 still reaches the server.
                    kwargs["content"] = json.dumps(request.body).encode()
                    kwargs["headers"] = {
                        **merged_headers,
                        "Content-Type": "application/json",
                    }
                return await client.request(request.method, request.url, **kwargs)

            try:
                import asyncio  # noqa: PLC0415 - only needed for the sleep seam

                response, retry_count = await self._retry.run(
                    do_request, request.retry, asyncio.sleep
                )
            except httpx.ConnectError as exc:
                message = str(exc)
                code = (
                    RemoteTriggerErrorCode.SSL_ERROR
                    if "certificate" in message.lower()
                    else RemoteTriggerErrorCode.CONNECTION_ERROR
                )
                logger.warning(
                    "remote_trigger: %s %s failed: %s",
                    request.method,
                    _sanitize_url(request.url),
                    message,
                )
                raise RemoteTriggerError(
                    code,
                    f"Failed to connect to {urllib.parse.urlsplit(request.url).hostname}: {message}",
                ) from exc

        elapsed_ms = int((time.monotonic() - start) * 1000)

        if response is None:
            logger.warning(
                "remote_trigger: timeout after %ss for %s",
                request.timeout,
                _sanitize_url(request.url),
            )
            return TriggerResponse(
                success=False,
                status_code=0,
                timestamp=_now_iso(),
                elapsed_ms=elapsed_ms,
                retry_count=retry_count,
                error=f"Request timeout after {request.timeout} seconds",
            )

        resp_body = _parse_body(response)
        extracted = _extract_fields(resp_body, request.extract_fields)
        passed, errors = self._validator.validate(
            response, elapsed_ms, request.validation, request.env
        )

        error_message = None
        if not passed:
            error_message = "; ".join(errors)
        elif response.status_code >= 400:
            error_message = _status_error_message(
                response.status_code, request.url, retry_count, request.retry
            )

        logger.info(
            "remote_trigger: %s %s -> %s (%sms, retries=%s)",
            request.method,
            _sanitize_url(request.url),
            response.status_code,
            elapsed_ms,
            retry_count,
        )

        return TriggerResponse(
            success=passed and response.status_code < 400,
            status_code=response.status_code,
            status_text=response.reason_phrase or "",
            timestamp=_now_iso(),
            elapsed_ms=elapsed_ms,
            headers=dict(response.headers),
            body=resp_body,
            extracted=extracted,
            validation_passed=passed,
            validation_errors=errors,
            retry_count=retry_count,
            error=error_message,
        )


@traces(spec="067", story="US-067-001", reqs=["REQ-067-001"])
def register_remote_trigger_tool(registry: ToolRegistry) -> RemoteTriggerTool:
    """Build a :class:`RemoteTriggerTool` and register it into ``registry``.

    Not wired into ``registry.py``'s default registration set per the build
    brief (new-files-only) — a caller (e.g. the volk tool-bootstrap path)
    invokes this explicitly to add ``remote_trigger`` to a live registry.
    """
    tool = RemoteTriggerTool()
    registry.register(tool, replace=True)
    return tool