chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user