Merge branch 'main' into rewbs/tool-use-charge-to-subscription
This commit is contained in:
@@ -13,6 +13,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,13 +83,31 @@ def _approval_key_aliases(pattern_key: str) -> set[str]:
|
||||
# Detection
|
||||
# =========================================================================
|
||||
|
||||
def _normalize_command_for_detection(command: str) -> str:
|
||||
"""Normalize a command string before dangerous-pattern matching.
|
||||
|
||||
Strips ANSI escape sequences (full ECMA-48 via tools.ansi_strip),
|
||||
null bytes, and normalizes Unicode fullwidth characters so that
|
||||
obfuscation techniques cannot bypass the pattern-based detection.
|
||||
"""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
|
||||
# Strip all ANSI escape sequences (CSI, OSC, DCS, 8-bit C1, etc.)
|
||||
command = strip_ansi(command)
|
||||
# Strip null bytes
|
||||
command = command.replace('\x00', '')
|
||||
# Normalize Unicode (fullwidth Latin, halfwidth Katakana, etc.)
|
||||
command = unicodedata.normalize('NFKC', command)
|
||||
return command
|
||||
|
||||
|
||||
def detect_dangerous_command(command: str) -> tuple:
|
||||
"""Check if a command matches any dangerous patterns.
|
||||
|
||||
Returns:
|
||||
(is_dangerous, pattern_key, description) or (False, None, None)
|
||||
"""
|
||||
command_lower = command.lower()
|
||||
command_lower = _normalize_command_for_detection(command).lower()
|
||||
for pattern, description in DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, command_lower, re.IGNORECASE | re.DOTALL):
|
||||
pattern_key = description
|
||||
|
||||
@@ -174,8 +174,10 @@ def _build_child_agent(
|
||||
|
||||
# When no explicit toolsets given, inherit from parent's enabled toolsets
|
||||
# so disabled tools (e.g. web) don't leak to subagents.
|
||||
parent_toolsets = set(getattr(parent_agent, "enabled_toolsets", None) or DEFAULT_TOOLSETS)
|
||||
if toolsets:
|
||||
child_toolsets = _strip_blocked_tools(toolsets)
|
||||
# Intersect with parent — subagent must not gain tools the parent lacks
|
||||
child_toolsets = _strip_blocked_tools([t for t in toolsets if t in parent_toolsets])
|
||||
elif parent_agent and getattr(parent_agent, "enabled_toolsets", None):
|
||||
child_toolsets = _strip_blocked_tools(parent_agent.enabled_toolsets)
|
||||
else:
|
||||
|
||||
@@ -490,7 +490,7 @@ async def _send_discord(token, chat_id, message):
|
||||
try:
|
||||
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
|
||||
headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.post(url, headers=headers, json={"content": message}) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
body = await resp.text()
|
||||
@@ -510,7 +510,7 @@ async def _send_slack(token, chat_id, message):
|
||||
try:
|
||||
url = "https://slack.com/api/chat.postMessage"
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.post(url, headers=headers, json={"channel": chat_id, "text": message}) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("ok"):
|
||||
@@ -649,7 +649,7 @@ async def _send_sms(auth_token, chat_id, message):
|
||||
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||
headers = {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field("From", from_number)
|
||||
form_data.add_field("To", chat_id)
|
||||
|
||||
@@ -178,10 +178,16 @@ async def _summarize_session(
|
||||
return None
|
||||
|
||||
|
||||
# Sources that are excluded from session browsing/searching by default.
|
||||
# Third-party integrations (Paperclip agents, etc.) tag their sessions with
|
||||
# HERMES_SESSION_SOURCE=tool so they don't clutter the user's session history.
|
||||
_HIDDEN_SESSION_SOURCES = ("tool",)
|
||||
|
||||
|
||||
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
|
||||
"""Return metadata for the most recent sessions (no LLM calls)."""
|
||||
try:
|
||||
sessions = db.list_sessions_rich(limit=limit + 5) # fetch extra to skip current
|
||||
sessions = db.list_sessions_rich(limit=limit + 5, exclude_sources=list(_HIDDEN_SESSION_SOURCES)) # fetch extra to skip current
|
||||
|
||||
# Resolve current session lineage to exclude it
|
||||
current_root = None
|
||||
@@ -265,6 +271,7 @@ def session_search(
|
||||
raw_results = db.search_messages(
|
||||
query=query,
|
||||
role_filter=role_list,
|
||||
exclude_sources=list(_HIDDEN_SESSION_SOURCES),
|
||||
limit=50, # Get more matches to find unique sessions
|
||||
offset=0,
|
||||
)
|
||||
|
||||
@@ -1050,15 +1050,27 @@ def _get_configured_model() -> str:
|
||||
|
||||
def _resolve_trust_level(source: str) -> str:
|
||||
"""Map a source identifier to a trust level."""
|
||||
prefix_aliases = (
|
||||
"skills-sh/",
|
||||
"skills.sh/",
|
||||
"skils-sh/",
|
||||
"skils.sh/",
|
||||
)
|
||||
normalized_source = source
|
||||
for prefix in prefix_aliases:
|
||||
if normalized_source.startswith(prefix):
|
||||
normalized_source = normalized_source[len(prefix):]
|
||||
break
|
||||
|
||||
# Agent-created skills get their own permissive trust level
|
||||
if source == "agent-created":
|
||||
if normalized_source == "agent-created":
|
||||
return "agent-created"
|
||||
# Official optional skills shipped with the repo
|
||||
if source.startswith("official/") or source == "official":
|
||||
if normalized_source.startswith("official/") or normalized_source == "official":
|
||||
return "builtin"
|
||||
# Check if source matches any trusted repo
|
||||
for trusted in TRUSTED_REPOS:
|
||||
if source.startswith(trusted) or source == trusted:
|
||||
if normalized_source.startswith(trusted) or normalized_source == trusted:
|
||||
return "trusted"
|
||||
return "community"
|
||||
|
||||
|
||||
@@ -925,19 +925,10 @@ class SkillsShSource(SkillSource):
|
||||
|
||||
def inspect(self, identifier: str) -> Optional[SkillMeta]:
|
||||
canonical = self._normalize_identifier(identifier)
|
||||
detail: Optional[dict] = None
|
||||
for candidate in self._candidate_identifiers(canonical):
|
||||
meta = self.github.inspect(candidate)
|
||||
if meta:
|
||||
detail = self._fetch_detail_page(canonical)
|
||||
return self._finalize_inspect_meta(meta, canonical, detail)
|
||||
|
||||
detail = self._fetch_detail_page(canonical)
|
||||
resolved = self._discover_identifier(canonical, detail=detail)
|
||||
if resolved:
|
||||
meta = self.github.inspect(resolved)
|
||||
if meta:
|
||||
return self._finalize_inspect_meta(meta, canonical, detail)
|
||||
meta = self._resolve_github_meta(canonical, detail=detail)
|
||||
if meta:
|
||||
return self._finalize_inspect_meta(meta, canonical, detail)
|
||||
return None
|
||||
|
||||
def _featured_skills(self, limit: int) -> List[SkillMeta]:
|
||||
@@ -1099,6 +1090,13 @@ class SkillsShSource(SkillSource):
|
||||
if self._matches_skill_tokens(meta, tokens):
|
||||
return meta.identifier
|
||||
|
||||
# Prefer a single recursive tree lookup before brute-forcing every
|
||||
# top-level directory. This avoids large request bursts on categorized
|
||||
# repos like borghei/claude-skills.
|
||||
tree_result = self.github._find_skill_in_repo_tree(repo, skill_token)
|
||||
if tree_result:
|
||||
return tree_result
|
||||
|
||||
# Fallback: scan repo root for directories that might contain skills
|
||||
try:
|
||||
root_url = f"https://api.github.com/repos/{repo}/contents/"
|
||||
@@ -1131,14 +1129,17 @@ class SkillsShSource(SkillSource):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final fallback: use the GitHub Trees API to find the skill anywhere
|
||||
# in the repo tree. This handles deeply nested structures like
|
||||
# cli-tool/components/skills/development/<skill>/ that the shallow
|
||||
# scan above can't reach.
|
||||
tree_result = self.github._find_skill_in_repo_tree(repo, skill_token)
|
||||
if tree_result:
|
||||
return tree_result
|
||||
return None
|
||||
|
||||
def _resolve_github_meta(self, identifier: str, detail: Optional[dict] = None) -> Optional[SkillMeta]:
|
||||
for candidate in self._candidate_identifiers(identifier):
|
||||
meta = self.github.inspect(candidate)
|
||||
if meta:
|
||||
return meta
|
||||
|
||||
resolved = self._discover_identifier(identifier, detail=detail)
|
||||
if resolved:
|
||||
return self.github.inspect(resolved)
|
||||
return None
|
||||
|
||||
def _finalize_inspect_meta(self, meta: SkillMeta, canonical: str, detail: Optional[dict]) -> SkillMeta:
|
||||
@@ -1264,10 +1265,15 @@ class SkillsShSource(SkillSource):
|
||||
|
||||
@staticmethod
|
||||
def _normalize_identifier(identifier: str) -> str:
|
||||
if identifier.startswith("skills-sh/"):
|
||||
return identifier[len("skills-sh/"):]
|
||||
if identifier.startswith("skills.sh/"):
|
||||
return identifier[len("skills.sh/"):]
|
||||
prefix_aliases = (
|
||||
"skills-sh/",
|
||||
"skills.sh/",
|
||||
"skils-sh/",
|
||||
"skils.sh/",
|
||||
)
|
||||
for prefix in prefix_aliases:
|
||||
if identifier.startswith(prefix):
|
||||
return identifier[len(prefix):]
|
||||
return identifier
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user