mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 00:11:39 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e929b66c1a |
@@ -477,7 +477,12 @@ def run_hermes_oauth_login() -> Optional[str]:
|
|||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
|
import secrets as _secrets
|
||||||
verifier, challenge = _generate_pkce()
|
verifier, challenge = _generate_pkce()
|
||||||
|
# Use a separate random value for state (CSRF protection).
|
||||||
|
# The code_verifier is the PKCE secret and must NEVER appear in the URL
|
||||||
|
# (browser history, proxy logs, Referer headers would leak it).
|
||||||
|
oauth_state = _secrets.token_urlsafe(16)
|
||||||
|
|
||||||
# Build authorization URL
|
# Build authorization URL
|
||||||
params = {
|
params = {
|
||||||
@@ -488,7 +493,7 @@ def run_hermes_oauth_login() -> Optional[str]:
|
|||||||
"scope": _OAUTH_SCOPES,
|
"scope": _OAUTH_SCOPES,
|
||||||
"code_challenge": challenge,
|
"code_challenge": challenge,
|
||||||
"code_challenge_method": "S256",
|
"code_challenge_method": "S256",
|
||||||
"state": verifier,
|
"state": oauth_state,
|
||||||
}
|
}
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
|
auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
|
||||||
@@ -615,7 +620,8 @@ def refresh_hermes_oauth_token() -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.dumps({
|
import urllib.parse
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": creds["refreshToken"],
|
"refresh_token": creds["refreshToken"],
|
||||||
"client_id": _OAUTH_CLIENT_ID,
|
"client_id": _OAUTH_CLIENT_ID,
|
||||||
@@ -625,7 +631,7 @@ def refresh_hermes_oauth_token() -> Optional[str]:
|
|||||||
_OAUTH_TOKEN_URL,
|
_OAUTH_TOKEN_URL,
|
||||||
data=data,
|
data=data,
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"User-Agent": f"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)",
|
"User-Agent": f"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)",
|
||||||
},
|
},
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -1056,8 +1062,9 @@ def build_anthropic_kwargs(
|
|||||||
# Anthropic has no tool_choice "none" — omit tools entirely to prevent use
|
# Anthropic has no tool_choice "none" — omit tools entirely to prevent use
|
||||||
kwargs.pop("tools", None)
|
kwargs.pop("tools", None)
|
||||||
elif isinstance(tool_choice, str):
|
elif isinstance(tool_choice, str):
|
||||||
# Specific tool name
|
# Specific tool name — must match the (possibly prefixed) tool definition
|
||||||
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
|
tc_name = (_MCP_TOOL_PREFIX + tool_choice) if is_oauth and not tool_choice.startswith(_MCP_TOOL_PREFIX) else tool_choice
|
||||||
|
kwargs["tool_choice"] = {"type": "tool", "name": tc_name}
|
||||||
|
|
||||||
# Map reasoning_config to Anthropic's thinking parameter.
|
# Map reasoning_config to Anthropic's thinking parameter.
|
||||||
# Claude 4.6 models use adaptive thinking + output_config.effort.
|
# Claude 4.6 models use adaptive thinking + output_config.effort.
|
||||||
|
|||||||
@@ -969,3 +969,61 @@ class TestToolChoice:
|
|||||||
tool_choice="search",
|
tool_choice="search",
|
||||||
)
|
)
|
||||||
assert kwargs["tool_choice"] == {"type": "tool", "name": "search"}
|
assert kwargs["tool_choice"] == {"type": "tool", "name": "search"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Security bug regression tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPKCESecurityBugs:
|
||||||
|
"""PKCE OAuth flow must not leak code_verifier via URL state parameter."""
|
||||||
|
|
||||||
|
def test_auth_url_does_not_contain_verifier(self):
|
||||||
|
"""The authorization URL must not contain the PKCE code_verifier.
|
||||||
|
The verifier is the PKCE secret — exposing it in the URL (via state param,
|
||||||
|
browser history, proxy logs) defeats PKCE entirely."""
|
||||||
|
import inspect
|
||||||
|
from agent.anthropic_adapter import run_hermes_oauth_login
|
||||||
|
source = inspect.getsource(run_hermes_oauth_login)
|
||||||
|
# The current code does "state": verifier — this is the bug
|
||||||
|
assert '"state": verifier' not in source, \
|
||||||
|
"PKCE code_verifier must not be used as OAuth state parameter — it leaks the secret in the URL"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshContentType:
|
||||||
|
"""Hermes OAuth refresh must use form-urlencoded per RFC 6749."""
|
||||||
|
|
||||||
|
def test_hermes_refresh_uses_form_urlencoded(self):
|
||||||
|
"""refresh_hermes_oauth_token must use application/x-www-form-urlencoded, not JSON."""
|
||||||
|
import inspect
|
||||||
|
from agent.anthropic_adapter import refresh_hermes_oauth_token
|
||||||
|
source = inspect.getsource(refresh_hermes_oauth_token)
|
||||||
|
# RFC 6749 Section 4.1.3 requires form-urlencoded for token endpoint
|
||||||
|
assert "application/x-www-form-urlencoded" in source, \
|
||||||
|
"Token refresh must use application/x-www-form-urlencoded per OAuth RFC 6749"
|
||||||
|
assert "application/json" not in source, \
|
||||||
|
"Token refresh must NOT use application/json for the token endpoint"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolChoiceOAuthPrefix:
|
||||||
|
"""When is_oauth=True, tool_choice name must get mcp_ prefix like tools do."""
|
||||||
|
|
||||||
|
_DUMMY_TOOL = [{"type": "function", "function": {"name": "search", "description": "s", "parameters": {"type": "object", "properties": {}}}}]
|
||||||
|
|
||||||
|
def test_specific_tool_choice_gets_mcp_prefix_with_oauth(self):
|
||||||
|
kwargs = build_anthropic_kwargs(
|
||||||
|
model="claude-sonnet-4-6",
|
||||||
|
messages=[{"role": "user", "content": "Hi"}],
|
||||||
|
tools=self._DUMMY_TOOL,
|
||||||
|
max_tokens=4096,
|
||||||
|
reasoning_config=None,
|
||||||
|
tool_choice="search",
|
||||||
|
is_oauth=True,
|
||||||
|
)
|
||||||
|
# Tools are prefixed with mcp_
|
||||||
|
tool_names = [t["name"] for t in kwargs["tools"]]
|
||||||
|
assert all(n.startswith("mcp_") for n in tool_names)
|
||||||
|
# tool_choice must also be prefixed to match
|
||||||
|
assert kwargs["tool_choice"]["name"] == "mcp_search", \
|
||||||
|
"tool_choice name must be mcp_-prefixed when is_oauth=True to match tool definitions"
|
||||||
|
|||||||
Reference in New Issue
Block a user