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
10 changes: 8 additions & 2 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,15 @@ jobs:
https://github.com/OpenRouterTeam/typescript-agent.git /tmp/upstream-check
git -C /tmp/upstream-check fetch -q --tags origin

TAG="@openrouter/agent@${VERSION}"
# Downstream-only patch releases: versions upstream never published,
# each mapped to the upstream release its tree is ported from.
case "$VERSION" in
0.8.1) BASE_VERSION="0.8.0" ;;
*) BASE_VERSION="$VERSION" ;;
esac
TAG="@openrouter/agent@${BASE_VERSION}"
if ! git -C /tmp/upstream-check rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "::error::Upstream has no release tag $TAG. This package's version tracks the ported @openrouter/agent version, so publishing $VERSION means upstream released it. Wait for the upstream release, or correct the version."
echo "::error::Upstream has no release tag $TAG. This package's version tracks the ported @openrouter/agent version, so publishing $VERSION means upstream released $BASE_VERSION. Wait for the upstream release, or correct the version."
exit 1
fi

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ name = "openrouter-agent-sdk"
# says plainly which upstream version it is a port of. The verifier enforces the
# match against upstream's package.json during a sync. See the Package Version
# section of .upstreamer/upstreamer.md.
version = "0.8.0"
version = "0.8.1"
description = "Python port of @openrouter/agent: OpenRouter tool orchestration, streaming, state, and format compatibility."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
37 changes: 32 additions & 5 deletions src/openrouter_agent/conversation_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import time
import uuid
from dataclasses import replace
from typing import Any, Dict, List, Mapping, Optional, Sequence
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple

from ._utils import dump, json_dumps, maybe_await
from pydantic import BaseModel

from ._utils import dump, json_dumps, maybe_await, validate_schema
from .tool_types import (
ConversationState,
ParsedToolCall,
Expand Down Expand Up @@ -189,14 +191,29 @@ def append_to_messages(current: Any, new_items: Sequence[Any]) -> List[Any]:
return [*normalize_input_to_array(current), *list(new_items)]


def _parse_tool_arguments(tool: Tool, arguments: Any) -> Tuple[bool, Any]:
"""Parse arguments with the schema `execute` validates through, so approval
predicates and `execute` see the same defaults and coercions."""
if isinstance(arguments, str):
return False, arguments
try:
parsed = validate_schema(get_tool_function(tool).get("input_schema"), arguments)
except Exception:
return False, arguments
if isinstance(parsed, BaseModel):
parsed = parsed.model_dump()
return True, parsed


async def tool_requires_approval(
tool_call: ParsedToolCall,
tools: Sequence[Tool],
context: Mapping[str, Any],
call_level_check: Any = None,
) -> bool:
if call_level_check is not None:
return bool(await maybe_await(call_level_check(tool_call, context)))
"""Call-level checks get a copy of the call with parsed arguments when parsing
succeeds. Tool-level predicates get the parsed arguments; invalid or
non-mapping arguments fail closed (require approval)."""
matching = next(
(
candidate
Expand All @@ -205,11 +222,21 @@ async def tool_requires_approval(
),
None,
)
if call_level_check is not None:
if matching is None:
return bool(await maybe_await(call_level_check(tool_call, context)))
ok, parsed = _parse_tool_arguments(matching, tool_call.arguments)
if not ok:
return bool(await maybe_await(call_level_check(tool_call, context)))
return bool(await maybe_await(call_level_check(replace(tool_call, arguments=parsed), context)))
if not matching:
return False
requirement = get_tool_function(matching).get("require_approval")
if callable(requirement):
return bool(await maybe_await(requirement(tool_call.arguments, context)))
ok, parsed = _parse_tool_arguments(matching, tool_call.arguments)
if not ok or not isinstance(parsed, Mapping):
return True
return bool(await maybe_await(requirement(parsed, context)))
return bool(requirement)


Expand Down
225 changes: 225 additions & 0 deletions tests/unit/test_approval_predicate_parsed_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"""Approval predicates see the same schema-parsed arguments `execute` gets,
and schema-invalid arguments fail closed. Subset of upstream
`tests/unit/approval-gate-regressions.test.ts`.
"""

from __future__ import annotations

import dataclasses
from typing import Any, Dict, List, Tuple

import pytest
from pydantic import BaseModel, field_validator

from openrouter_agent import tool
from openrouter_agent.conversation_state import tool_requires_approval
from openrouter_agent.tool_executor import execute_regular_tool
from openrouter_agent.tool_types import ParsedToolCall, Tool

CONTEXT: Dict[str, Any] = {"number_of_turns": 1}


class Recorder:
"""Counts invocations and records arguments; returns a fixed value.

A class (not `lambda: calls.append(...) or value`) so the return value is
explicit and mypy-clean -- see tests/unit/test_tool_execution_once.py.
"""

def __init__(self, value: Any = None) -> None:
self.value = value
self.calls: List[Tuple[Any, ...]] = []

def __call__(self, *args: Any) -> Any:
self.calls.append(args)
return self.value

@property
def count(self) -> int:
return len(self.calls)


def _dumped_first_args(recorder: Recorder) -> List[Any]:
return [call[0].model_dump() if isinstance(call[0], BaseModel) else call[0] for call in recorder.calls]


# ---------------------------------------------------------------------------
# Bug 1: the approval predicate must see the SAME arguments `execute` gets.
# ---------------------------------------------------------------------------


class DangerousDefaultArgs(BaseModel):
dangerous: bool = True


class AmountArgs(BaseModel):
amount: int


class DestructiveDefaultArgs(BaseModel):
destructive: bool = True


class TargetArgs(BaseModel):
target: str


class OkOutput(BaseModel):
ok: bool


async def test_applies_input_schema_defaults_before_function_require_approval() -> None:
predicate = Recorder()

def require(params: Any, ctx: Any) -> bool:
predicate(params, ctx)
return params["dangerous"] is True

conditional = tool(
name="conditional_action",
input_schema=DangerousDefaultArgs,
require_approval=require,
execute=lambda params, ctx: {},
)

# The model emitted `{}`; the schema default makes `dangerous` True by the
# time execute runs, so the predicate must see it True too.
requires = await tool_requires_approval(
ParsedToolCall(id="1", name="conditional_action", arguments={}), [conditional], CONTEXT
)

# Pydantic models are model_dump()'d for the predicate (conversation_state.py
# _parse_tool_arguments): the predicate contract is a mapping, as upstream.
assert [call[0] for call in predicate.calls] == [{"dangerous": True}]
assert requires is True


async def test_applies_input_schema_coercions_before_function_require_approval() -> None:
predicate = Recorder()

def require(params: Any, ctx: Any) -> bool:
predicate(params, ctx)
return bool(params["amount"] > 100)

transfer = tool(
name="transfer",
input_schema=AmountArgs,
require_approval=require,
execute=lambda params, ctx: {},
)

# String amount is coerced (Pydantic lax mode stands in for z.coerce), so
# the comparison is numeric, not lexicographic.
requires = await tool_requires_approval(
ParsedToolCall(id="2", name="transfer", arguments={"amount": "500"}), [transfer], CONTEXT
)

assert [call[0] for call in predicate.calls] == [{"amount": 500}]
assert requires is True


async def test_applies_schema_defaults_for_call_level_checks_without_mutating_call() -> None:
defaulted = tool(
name="call_level_defaulted_action",
input_schema=DestructiveDefaultArgs,
execute=lambda params, ctx: {},
)
tool_call = ParsedToolCall(id="call-level-default", name="call_level_defaulted_action", arguments={})
call_level_check = Recorder(True)

requires = await tool_requires_approval(tool_call, [defaulted], CONTEXT, call_level_check)

assert call_level_check.calls == [(dataclasses.replace(tool_call, arguments={"destructive": True}), CONTEXT)]
assert tool_call.arguments == {}
assert requires is True


@pytest.mark.parametrize("decision", [False, True])
async def test_honors_call_level_check_when_arguments_fail_schema_validation(decision: bool) -> None:
strict = tool(name="call_level_invalid_action", input_schema=TargetArgs, execute=lambda params, ctx: {})
invalid_call = ParsedToolCall(id=f"call-level-invalid-{decision}", name="call_level_invalid_action", arguments={})
call_level_check = Recorder(decision)

assert await tool_requires_approval(invalid_call, [strict], CONTEXT, call_level_check) is decision
# Parsing failed, so the call-level check receives the raw call.
assert call_level_check.calls == [(invalid_call, CONTEXT)]


async def test_parses_original_wire_arguments_once_for_predicate_and_once_for_execution() -> None:
transform_calls = {"n": 0}

class TransformedArgs(BaseModel):
value: str

@field_validator("value")
@classmethod
def _suffix(cls, value: str) -> str:
transform_calls["n"] += 1
return f"{value}:{transform_calls['n']}"

predicate = Recorder(False)
execute = Recorder({})
transformed = tool(
name="transformed_action",
input_schema=TransformedArgs,
require_approval=predicate,
execute=execute,
)
tool_call = ParsedToolCall(id="non-idempotent-transform", name="transformed_action", arguments={"value": "wire"})

assert await tool_requires_approval(tool_call, [transformed], CONTEXT) is False
await execute_regular_tool(transformed, tool_call, CONTEXT)

assert predicate.calls == [({"value": "wire:1"}, CONTEXT)]
assert _dumped_first_args(execute) == [{"value": "wire:2"}]
assert transform_calls["n"] == 2
assert tool_call.arguments == {"value": "wire"}


async def test_fails_closed_when_arguments_fail_schema_validation() -> None:
predicate = Recorder(False)
strict = tool(name="strict_action", input_schema=TargetArgs, require_approval=predicate, execute=lambda p, c: {})

requires = await tool_requires_approval(
ParsedToolCall(id="3", name="strict_action", arguments={}), [strict], CONTEXT
)

assert requires is True
assert predicate.count == 0


async def test_still_fails_closed_when_schema_parses_to_non_object_value() -> None:
predicate = Recorder(False)
weird = tool(name="weird_action", input_schema=TargetArgs, require_approval=predicate, execute=lambda p, c: {})
# Mirrors upstream swapping in a schema whose parse succeeds with a
# non-record payload: a callable schema is honored by validate_schema.
weird["function"]["input_schema"] = lambda value: ["not", "a", "record"]

requires = await tool_requires_approval(
ParsedToolCall(id="4", name="weird_action", arguments={"target": "x"}), [weird], CONTEXT
)

assert requires is True
assert predicate.count == 0


async def test_still_fails_closed_on_schema_invalid_arguments_for_manual_tools() -> None:
predicate = Recorder(False)
# A manual tool: no execute / on_tool_called. The host runs it as-is, so a
# malformed call must not be waved past the gate.
manual_tool: Tool = {
"type": "function",
"function": {"name": "manual_action", "input_schema": TargetArgs, "require_approval": predicate},
}

requires = await tool_requires_approval(
ParsedToolCall(id="5", name="manual_action", arguments={}), [manual_tool], CONTEXT
)

assert requires is True
assert predicate.count == 0


# ---------------------------------------------------------------------------
# Approval vs. PreToolUse-mutated arguments.
# ---------------------------------------------------------------------------
6 changes: 6 additions & 0 deletions upstreamer-changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# openrouter-agent-sdk Changelog

## 0.8.1 — approval fix

Downstream-only fix on top of the 0.8.0 port; no new upstream work.

- **Security:** callable `require_approval` predicates and call-level approval checks now see the arguments after `input_schema` parsing (coercions and defaults applied), the same values `execute` receives. Arguments that fail parsing require approval. Before this, a value such as the string `"true"` could pass a strict predicate and then run as boolean `True`.

## 0.8.0 — first PyPI release

First published release. The version is `0.8.0`, not `0.0.1`, because this
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading