258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""Inline internal JSON Schema ``$ref`` pointers.
|
|
|
|
Python counterpart of the TypeScript SDK's ``dereferenceJsonSchema``
|
|
(``ts/packages/core/src/utils/jsonSchema.ts``). Hand-rolled schema walkers in
|
|
the SDK (e.g. the file-upload/download substitution in
|
|
:mod:`composio.core.models._files`) recurse through ``properties``/``anyOf``/
|
|
``oneOf``/``allOf``/``items`` but do not dereference ``$ref``. The Composio API
|
|
emits tool parameter schemas with ``$ref``/``$defs`` indirection (``GMAIL_GET_
|
|
ATTACHMENT`` is the canonical shape), so a ``file_uploadable``/``file_
|
|
downloadable`` flag reachable only through a reference is silently missed.
|
|
|
|
Resolving references *once* at the boundary — rather than teaching every walker
|
|
to dereference — keeps the walkers reference-agnostic and gives all of them
|
|
``$ref`` support for free, including keywords they never special-cased
|
|
(``additionalProperties``, ``patternProperties``, ``prefixItems``, ``not``, …),
|
|
because :func:`dereference_json_schema` walks containers reflectively.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typing as t
|
|
|
|
from composio.exceptions import JSONSchemaRefResolutionError
|
|
from composio.utils.logging import get as get_logger
|
|
|
|
logger = get_logger()
|
|
|
|
# A ``$ref`` chain longer than this, or object nesting deeper than this, is
|
|
# treated as pathological (cyclic data, a billion-laughs-style schema) and
|
|
# raises rather than exhausting the interpreter's recursion limit. The caps
|
|
# fire in both strict and lenient modes — leniency applies to *unresolvable*
|
|
# refs, not to runaway expansion.
|
|
MAX_REF_CHAIN_DEPTH = 100
|
|
MAX_NODE_DEPTH = 512
|
|
|
|
# In-band hint attached to the sentinel when lenient mode stands in for an
|
|
# unresolvable ``$ref``. Makes the degradation visible to an LLM reading the
|
|
# schema instead of leaving it a bare permissive object. Overridden by a
|
|
# caller-provided ``description`` sibling (Draft 2020-12 sibling-keyword merge).
|
|
UNRESOLVED_REF_DESCRIPTION = (
|
|
"Schema shape unresolved at the source — validate loosely. "
|
|
"See https://github.com/ComposioHQ/composio/issues/3307."
|
|
)
|
|
|
|
# Strategy when an internal ``$ref`` cannot be resolved.
|
|
# - ``"throw"`` (default): raise ``JSONSchemaRefResolutionError``. Right for
|
|
# first-party / custom-tool schemas where a dangling ``$ref`` is a bug.
|
|
# - ``"sentinel"``: replace the node with a permissive object. Right for
|
|
# schemas from an upstream service the caller cannot edit (the Composio API
|
|
# ships some ``output_parameters`` with a ``$ref`` into ``#/$defs/...`` but no
|
|
# ``$defs`` block — see https://github.com/ComposioHQ/composio/issues/3307).
|
|
UnresolvedRefStrategy = t.Literal["throw", "sentinel"]
|
|
UnresolvedRefReason = t.Literal["missing-target", "malformed-pointer"]
|
|
|
|
# Callback invoked once per replaced node in ``"sentinel"`` mode.
|
|
OnReplace = t.Callable[[str, "UnresolvedRefReason"], None]
|
|
|
|
|
|
def _cycle_break_sentinel() -> t.Dict[str, t.Any]:
|
|
"""A fresh permissive object used to break cycles / stand in for danglers."""
|
|
return {"type": "object", "additionalProperties": True}
|
|
|
|
|
|
def _is_mapping(value: t.Any) -> bool:
|
|
return isinstance(value, dict)
|
|
|
|
|
|
def _decode_pointer_segment(segment: str) -> str:
|
|
# Order matters: ``~01`` must decode to ``~1`` (literal), not ``/``.
|
|
return segment.replace("~1", "/").replace("~0", "~")
|
|
|
|
|
|
class _Resolution(t.NamedTuple):
|
|
ok: bool
|
|
value: t.Any = None
|
|
reason: t.Optional[UnresolvedRefReason] = None
|
|
failed_at: t.Optional[str] = None
|
|
|
|
|
|
def _try_resolve_pointer(root: t.Dict[str, t.Any], pointer: str) -> _Resolution:
|
|
"""Walk a local JSON Pointer (``#/a/b/0``) against ``root``.
|
|
|
|
Returns a tagged result so the caller can distinguish a malformed pointer
|
|
from a structurally valid pointer whose target is absent.
|
|
"""
|
|
if pointer in ("#", ""):
|
|
return _Resolution(ok=True, value=root)
|
|
if not pointer.startswith("#/"):
|
|
return _Resolution(ok=False, reason="malformed-pointer")
|
|
|
|
cursor: t.Any = root
|
|
for raw_segment in pointer[2:].split("/"):
|
|
segment = _decode_pointer_segment(raw_segment)
|
|
if isinstance(cursor, list):
|
|
try:
|
|
index = int(segment)
|
|
except ValueError:
|
|
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
|
if index < 0 or index >= len(cursor):
|
|
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
|
cursor = cursor[index]
|
|
elif isinstance(cursor, dict):
|
|
if segment not in cursor:
|
|
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
|
cursor = cursor[segment]
|
|
else:
|
|
return _Resolution(ok=False, reason="missing-target", failed_at=segment)
|
|
return _Resolution(ok=True, value=cursor)
|
|
|
|
|
|
def _raise_resolution_error(pointer: str, resolution: _Resolution) -> t.NoReturn:
|
|
if resolution.reason == "malformed-pointer":
|
|
raise JSONSchemaRefResolutionError(
|
|
f"Unsupported $ref pointer: {pointer}",
|
|
meta={"ref": pointer},
|
|
)
|
|
meta: t.Dict[str, t.Any] = {"ref": pointer}
|
|
if resolution.failed_at is not None:
|
|
meta["failed_at"] = resolution.failed_at
|
|
raise JSONSchemaRefResolutionError(
|
|
f"Cannot resolve $ref {pointer}",
|
|
meta=meta,
|
|
)
|
|
|
|
|
|
def dereference_json_schema(
|
|
schema: t.Any,
|
|
*,
|
|
on_unresolved: UnresolvedRefStrategy = "throw",
|
|
on_replace: t.Optional[OnReplace] = None,
|
|
) -> t.Any:
|
|
"""Inline internal ``$ref`` pointers (``#/$defs/...`` and legacy
|
|
``#/definitions/...``), returning a new schema; the input is never mutated.
|
|
|
|
External refs (``http://``, ``https://``, …) are left untouched (and logged
|
|
once for audit, since a downstream resolver fetching them could enable SSRF
|
|
or local-file disclosure). Cycles — both ``$ref`` cycles and JS-object-style
|
|
identity cycles — are broken with a permissive ``{"type": "object",
|
|
"additionalProperties": True}`` sentinel. ``$defs``/``definitions`` are
|
|
stripped from the returned root once everything is inlined.
|
|
|
|
:param on_unresolved: see :data:`UnresolvedRefStrategy`.
|
|
:param on_replace: invoked once per replaced node in ``"sentinel"`` mode,
|
|
with the original pointer and the reason it could not be resolved.
|
|
:raises JSONSchemaRefResolutionError: on malformed pointers or missing
|
|
targets (strict mode only), or chains/nesting past the safety caps
|
|
(both modes).
|
|
"""
|
|
if not _is_mapping(schema):
|
|
return schema
|
|
|
|
root = t.cast(t.Dict[str, t.Any], schema)
|
|
# Identity-based cycle guard for the *current* walk path. Python doesn't
|
|
# suffer JS-style prototype pollution, so unlike the TS port there's no
|
|
# need to filter ``__proto__``/``constructor`` keys while cloning.
|
|
visiting: t.Set[int] = set()
|
|
|
|
# ``walk`` uses explicit loops (not comprehensions) so each level of schema
|
|
# nesting costs exactly one Python stack frame. That keeps ``MAX_NODE_DEPTH``
|
|
# the binding limit under the interpreter's default recursion ceiling, so a
|
|
# pathologically deep schema fails with our typed error rather than a bare
|
|
# ``RecursionError`` (which is also caught at the call site as a backstop).
|
|
def walk(
|
|
node: t.Any,
|
|
visited_refs: t.FrozenSet[str],
|
|
chain_depth: int,
|
|
node_depth: int,
|
|
) -> t.Any:
|
|
if node_depth >= MAX_NODE_DEPTH:
|
|
raise JSONSchemaRefResolutionError(
|
|
f"JSON Schema node depth exceeded cap ({MAX_NODE_DEPTH})"
|
|
)
|
|
|
|
if isinstance(node, list):
|
|
node_id = id(node)
|
|
if node_id in visiting:
|
|
return _cycle_break_sentinel()
|
|
visiting.add(node_id)
|
|
try:
|
|
cloned_list: t.List[t.Any] = []
|
|
for item in node:
|
|
cloned_list.append(
|
|
walk(item, visited_refs, chain_depth, node_depth + 1)
|
|
)
|
|
return cloned_list
|
|
finally:
|
|
visiting.discard(node_id)
|
|
|
|
if not _is_mapping(node):
|
|
return node
|
|
|
|
node_id = id(node)
|
|
if node_id in visiting:
|
|
return _cycle_break_sentinel()
|
|
visiting.add(node_id)
|
|
try:
|
|
ref = node.get("$ref")
|
|
ref = ref if isinstance(ref, str) else None
|
|
|
|
# External refs and non-``$ref`` nodes both pass through the same
|
|
# reflective clone path.
|
|
if ref is None or not ref.startswith("#"):
|
|
if ref is not None:
|
|
logger.warning("Leaving external $ref untouched: %s", ref)
|
|
cloned: t.Dict[str, t.Any] = {}
|
|
for key, value in node.items():
|
|
cloned[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
|
|
return cloned
|
|
|
|
if chain_depth >= MAX_REF_CHAIN_DEPTH:
|
|
raise JSONSchemaRefResolutionError(
|
|
f"JSON Schema $ref chain exceeded depth cap "
|
|
f"({MAX_REF_CHAIN_DEPTH}): {ref}",
|
|
meta={"ref": ref},
|
|
)
|
|
if ref in visited_refs:
|
|
return _cycle_break_sentinel()
|
|
|
|
resolution = _try_resolve_pointer(root, ref)
|
|
if resolution.ok:
|
|
target: t.Any = resolution.value
|
|
elif on_unresolved == "sentinel":
|
|
if on_replace is not None and resolution.reason is not None:
|
|
on_replace(ref, resolution.reason)
|
|
target = {
|
|
**_cycle_break_sentinel(),
|
|
"description": UNRESOLVED_REF_DESCRIPTION,
|
|
}
|
|
else:
|
|
_raise_resolution_error(ref, resolution)
|
|
|
|
next_refs = visited_refs | {ref}
|
|
resolved = walk(target, next_refs, chain_depth + 1, node_depth + 1)
|
|
|
|
# Shallow-merge sibling keywords next to ``$ref`` (Draft 2020-12:
|
|
# siblings win on collision). Draft 7 ignores them, but the Composio
|
|
# tool surface admits both, so we honor siblings for safety.
|
|
siblings = {key: value for key, value in node.items() if key != "$ref"}
|
|
if not siblings or not _is_mapping(resolved):
|
|
return resolved
|
|
merged = dict(resolved)
|
|
for key, value in siblings.items():
|
|
merged[key] = walk(value, visited_refs, chain_depth, node_depth + 1)
|
|
return merged
|
|
finally:
|
|
visiting.discard(node_id)
|
|
|
|
try:
|
|
out = walk(root, frozenset(), 0, 0)
|
|
except RecursionError as exc: # pragma: no cover - depth cap normally fires first
|
|
raise JSONSchemaRefResolutionError(
|
|
"JSON Schema nesting too deep to dereference"
|
|
) from exc
|
|
if _is_mapping(out):
|
|
out.pop("$defs", None)
|
|
out.pop("definitions", None)
|
|
return out
|