Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Require FortifyRoot admins to review all repository changes.
* @sayonfortify @manas-fortifyroot
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ This table lists the launch-supported instrumentation exposed through `fortifyro
| LangGraph | via `Instruments.LANGCHAIN` | install with app | Covered through LangChain/OpenAI launch path | Yes | Yes | Provider-dependent | Same supported path as LangChain |
| LlamaIndex | `Instruments.LLAMA_INDEX` | `llamaindex` | `llama-index >=0.14.12,<0.15.0` | Yes | Yes | Yes | Prompt + completion, including streaming paths |

For provider-role behavior, routed providers such as OpenRouter, LiteLLM, Bedrock, Azure OpenAI, and planned/mapper-supported providers, see [Provider Support](docs/PROVIDERS.md). That document is the source of truth for what is launch-certified versus planned.
For provider-role behavior, routed providers such as OpenRouter, LiteLLM, Bedrock, Azure OpenAI, and planned/mapper-supported providers, see [Provider Support](https://github.com/FortifyRoot/ocelle-py/blob/main/docs/PROVIDERS.md). That document is the source of truth for what is launch-certified versus planned.

## Runtime Safety

Expand Down Expand Up @@ -217,4 +217,4 @@ Safety rules can still run when configured; content tracing controls what is exp

Apache License, Version 2.0.

FortifyRoot Ocelle includes code derived from [OpenLLMetry](https://github.com/traceloop/openllmetry) and `traceloop-sdk` by Traceloop, licensed under the Apache License, Version 2.0. The license text and attribution are retained in [LICENSE](LICENSE).
FortifyRoot Ocelle includes code derived from [OpenLLMetry](https://github.com/traceloop/openllmetry) and `traceloop-sdk` by Traceloop, licensed under the Apache License, Version 2.0. The license text and attribution are retained in [LICENSE](https://github.com/FortifyRoot/ocelle-py/blob/main/LICENSE).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fortifyroot-ocelle"
version = "1.0.0"
version = "1.0.1"
description = "FortifyRoot Ocelle SDK for LLM observability"
authors = ["FortifyRoot <sdk@fortifyroot.com>"]
license = "Apache-2.0"
Expand Down
31 changes: 29 additions & 2 deletions scripts/vendor_openllmetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,14 @@ def get_git_info(ol_repo: Path) -> dict:
"""Get git commit info from OpenLLMetry repository."""
import subprocess

info = {"commit": "unknown", "branch": "unknown", "tag": "unknown"}
info = {
"commit": "unknown",
"branch": "unknown",
"tag": "unknown",
"base_tag": "unknown",
"dirty": False,
"dirty_files": [],
}

try:
result = subprocess.run(
Expand All @@ -603,7 +610,24 @@ def get_git_info(ol_repo: Path) -> dict:
cwd=ol_repo, capture_output=True, text=True
)
if result.returncode == 0:
info["tag"] = result.stdout.strip()
tag = result.stdout.strip()
info["tag"] = tag
info["base_tag"] = tag

result = subprocess.run(
['git', 'status', '--porcelain'],
cwd=ol_repo, capture_output=True, text=True
)
if result.returncode == 0:
dirty_files = [
line[3:]
for line in result.stdout.splitlines()
if len(line) > 3
]
info["dirty"] = bool(dirty_files)
info["dirty_files"] = dirty_files
if dirty_files:
info["tag"] = "dirty-working-tree"
except FileNotFoundError:
pass

Expand All @@ -623,6 +647,9 @@ def write_manifest(vendor_root: Path, ol_repo: Path, vendored_packages: List[str
"git_commit": git_info["commit"],
"git_branch": git_info["branch"],
"git_tag": git_info["tag"],
"git_base_tag": git_info["base_tag"],
"git_dirty": git_info["dirty"],
"git_dirty_files": git_info["dirty_files"],
"instrumentation_package_policy": {
f"opentelemetry-instrumentation-{pkg.replace('_', '-')}": should_vendor
for pkg, should_vendor in sorted(OL_INSTRUMENTATION_PACKAGES.items())
Expand Down
62 changes: 37 additions & 25 deletions src/fortifyroot/_internal/safety/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import concurrent.futures
import importlib
import logging
import re
Expand Down Expand Up @@ -32,11 +31,9 @@

_USING_RE2 = True
except ImportError:
_re_engine = re # type: ignore[assignment]
_re_engine = None # type: ignore[assignment]
_USING_RE2 = False

REGEX_EVAL_TIMEOUT_SECONDS = 5

_udf_detectors_enabled = False


Expand All @@ -63,6 +60,10 @@ def set_udf_detectors_enabled(enabled: bool) -> None:
"fortifyroot.safety.udf_errors",
description="Number of safety UDF load or execution errors.",
)
_REGEX_RULES_SKIPPED_COUNTER = _METER.create_counter(
"fortifyroot.safety.regex_rules_skipped",
description="Number of regex safety rules skipped before evaluation.",
)

SDK_LANGUAGE_PYTHON = "PYTHON"
MAX_REGEX_PATTERN_LENGTH = 1024
Expand Down Expand Up @@ -163,6 +164,21 @@ def _compile_rule(rule: SafetyRule, default_action: str) -> CompiledSafetyRule |
udf_detector = None

if isinstance(rule.matcher, RegexMatcher):
if not _USING_RE2 or _re_engine is None:
logger.error(
"Skipping regex safety rule because google-re2 is unavailable; "
"regex-based safety masking is degraded: %s",
rule.name,
)
_REGEX_RULES_SKIPPED_COUNTER.add(
1,
attributes={
"reason": "re2_unavailable",
"category": rule.category,
"severity": rule.severity,
},
)
return None
if len(rule.matcher.pattern) > MAX_REGEX_PATTERN_LENGTH:
logger.warning(
"Skipping safety rule with oversized regex: %s",
Expand All @@ -171,7 +187,7 @@ def _compile_rule(rule: SafetyRule, default_action: str) -> CompiledSafetyRule |
return None
try:
regex = _re_engine.compile(rule.matcher.pattern)
except (re.error, Exception):
except Exception:
logger.warning("Skipping safety rule with invalid regex: %s", rule.name)
return None
elif isinstance(rule.matcher, StringListMatcher):
Expand Down Expand Up @@ -246,30 +262,26 @@ def _safe_finditer(
pattern: re.Pattern[str],
text: str,
rule_name: str,
) -> list[re.Match[str]]:
"""Return all matches for *pattern* against *text*.
) -> Iterable[re.Match[str]]:
"""Return a lazy match iterator for *pattern* against *text*.

When ``re2`` is available the call is direct (re2 has built-in
backtracking protection). With stdlib ``re`` the evaluation is
delegated to a thread so we can enforce a timeout.
Regex safety rules are compiled only when google-re2 is available. This
fallback branch is retained as a defense-in-depth guard for tests and
unexpected runtime mutation, but it never executes untrusted stdlib regexes.
"""
if _USING_RE2:
return list(pattern.finditer(text))
return pattern.finditer(text)

def _do_finditer() -> list[re.Match[str]]:
return list(pattern.finditer(text))

with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(_do_finditer)
try:
return future.result(timeout=REGEX_EVAL_TIMEOUT_SECONDS)
except concurrent.futures.TimeoutError:
logger.warning(
"Regex evaluation timed out after %ds for rule %s; returning empty findings",
REGEX_EVAL_TIMEOUT_SECONDS,
rule_name,
)
raise _RegexTimeoutError() from None
logger.error(
"Skipping regex evaluation for rule %s because google-re2 is unavailable; "
"regex-based safety masking is degraded",
rule_name,
)
_REGEX_RULES_SKIPPED_COUNTER.add(
1,
attributes={"reason": "re2_unavailable_runtime"},
)
raise _RegexTimeoutError() from None


def _evaluate_rule(
Expand Down
12 changes: 10 additions & 2 deletions src/fortifyroot/_internal/safety/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,18 @@
"fortifyroot.safety.config_fetch_failures",
description="Number of FortifyRoot safety config fetch failures.",
)
_EMPTY_SNAPSHOT_START_COUNTER = _METER.create_counter(
"fortifyroot.safety.empty_snapshot_starts",
description="Number of SDK starts that registered safety handlers without a loaded config snapshot.",
)

MAX_CONFIG_RESPONSE_BYTES = 1_048_576 # 1 MB
DEFAULT_CONFIG_POLL_INTERVAL_SECONDS = 60
DEFAULT_STREAM_HOLDBACK_CHARS = 128
API_KEY_HEADER = "X-API-Key"
REQUEST_TIMEOUT_SECONDS = 5
FORTIFYROOT_API_BASE_URL = "https://api.fortifyroot.com"
LOCAL_FORTIFYROOT_DEV_HOSTS = {"localhost", "host.docker.internal"}
LOCAL_FORTIFYROOT_DEV_HOSTS = {"localhost"}
FORTIFYROOT_API_HOST_SUFFIX = "api.fortifyroot.com"
AUTH_HTTP_STATUS_CODES = {401, 403}

Expand Down Expand Up @@ -341,7 +345,11 @@ def _log_fetch_error(
return

if initial:
logger.warning(
_EMPTY_SNAPSHOT_START_COUNTER.add(
1,
attributes={"reason": exc.__class__.__name__},
)
logger.error(
"Initial FortifyRoot safety config fetch failed; starting with empty safety snapshot: %s",
exc,
)
Expand Down
2 changes: 1 addition & 1 deletion src/fortifyroot/_vendor/VENDOR_DEPENDENCIES.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
},
"test": {
"anthropic": ">=0.25.2,<0.83.0",
"boto3": ">=1.34.120,<2",
"boto3": ">=1.35.49,<2",
"chromadb": ">=0.5.23,<0.6.0",
"google-genai": ">=1.58.0,<1.59.0",
"langchain": ">=1.0.0,<2.0.0",
Expand Down
9 changes: 6 additions & 3 deletions src/fortifyroot/_vendor/VENDOR_MANIFEST.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
{
"vendored_at": "2026-06-27T19:30:27.492730",
"vendored_at": "2026-07-06T21:20:43.609893",
"openllmetry_version": "0.52.6",
"git_commit": "86389b2e0d35",
"git_commit": "ea31a61a6518",
"git_branch": "HEAD",
"git_tag": "fr-v0.52.6.33",
"git_tag": "fr-v0.52.6.34",
"git_base_tag": "fr-v0.52.6.34",
"git_dirty": false,
"git_dirty_files": [],
"instrumentation_package_policy": {
"opentelemetry-instrumentation-agno": false,
"opentelemetry-instrumentation-alephalpha": false,
Expand Down
13 changes: 11 additions & 2 deletions src/fortifyroot/_vendor/tracer/sdk/exporters/auth_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,17 @@ def _endpoint_label(endpoint: Any) -> str:
if not raw_endpoint:
return "unknown endpoint"
parsed = urlparse(raw_endpoint)
if parsed.netloc:
return parsed.netloc
if parsed.hostname:
host = parsed.hostname
if ":" in host and not host.startswith("["):
host = f"[{host}]"
try:
port = parsed.port
except ValueError:
return raw_endpoint
if port is not None:
return f"{host}:{port}"
return host
return raw_endpoint


Expand Down
65 changes: 62 additions & 3 deletions src/fortifyroot/_vendor/tracer/sdk/logging/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# This file has been modified by FortifyRoot.
# Original source: https://github.com/traceloop/openllmetry

import ipaddress
import logging
from typing import Dict, Optional, Any, cast
from urllib.parse import urlparse
Expand All @@ -17,6 +18,59 @@
FortifyRootHTTPLogExporter as HTTPExporter,
)

LOCAL_EXPORT_HOSTS = {"localhost"}


def _is_local_export_host(host: Optional[str]) -> bool:
if not host:
return False
normalized = host.strip().lower()
if normalized in LOCAL_EXPORT_HOSTS:
return True
try:
return ipaddress.ip_address(normalized).is_loopback
except ValueError:
return False


def _resolve_grpc_exporter_endpoint(endpoint: str) -> tuple[str, bool]:
trimmed_endpoint = endpoint.strip()
if "://" not in trimmed_endpoint:
return trimmed_endpoint, False

parsed = urlparse(trimmed_endpoint)
if parsed.username or parsed.password:
raise ValueError("OTLP exporter endpoints must not include username or password")
scheme = parsed.scheme.lower()
if scheme == "grpcs":
return parsed.netloc, False
if scheme == "grpc":
if not _is_local_export_host(parsed.hostname):
raise ValueError(
"grpc:// OTLP export is insecure and is allowed only for local "
"development endpoints; use grpcs:// or https:// for remote collectors"
)
return parsed.netloc, True

raise ValueError(
f"Unsupported OTLP exporter endpoint scheme {parsed.scheme!r}; "
"use https://, http://, grpcs://, grpc://localhost, or a bare secure gRPC host:port"
)


def _validate_http_exporter_endpoint(endpoint: str) -> None:
parsed = urlparse(endpoint.strip())
if parsed.username or parsed.password:
raise ValueError("OTLP exporter endpoints must not include username or password")
if parsed.scheme.lower() != "http":
return
if _is_local_export_host(parsed.hostname):
return
raise ValueError(
"http:// OTLP export is insecure and is allowed only for local "
"development endpoints; use https:// for remote collectors"
)


_FORTIFYROOT_LOGGING_HANDLER_MARKER = "_fortifyroot_logging_handler"
_FORTIFYROOT_INTERNAL_EXPORTER_LOGGER_PREFIX = "fortifyroot.sdk.exporters."
Expand Down Expand Up @@ -75,13 +129,18 @@ def get_logging_provider(cls) -> Optional[LoggerProvider]:

def init_logging_exporter(endpoint: str, headers: Dict[str, str]) -> LogExporter:
trimmed_endpoint = endpoint.strip()
if urlparse(trimmed_endpoint).scheme.lower() in {"http", "https"}:
scheme = urlparse(trimmed_endpoint).scheme.lower()
if scheme in {"http", "https"}:
_validate_http_exporter_endpoint(trimmed_endpoint)
base_url = trimmed_endpoint.rstrip("/")
if not base_url.endswith("/v1/logs"):
base_url = f"{base_url}/v1/logs"
return cast(LogExporter, HTTPExporter(endpoint=base_url, headers=headers))
else:
return cast(LogExporter, GRPCExporter(endpoint=trimmed_endpoint, headers=headers))
grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(trimmed_endpoint)
return cast(
LogExporter,
GRPCExporter(endpoint=grpc_endpoint, headers=headers, insecure=insecure),
)


def init_logging_provider(
Expand Down
Loading
Loading