mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
SKY-11498: Route request-policy classifier through lite Copilot model setting (#7191)
This commit is contained in:
parent
95d1e966fa
commit
b7c1383f81
15 changed files with 383 additions and 150 deletions
|
|
@ -350,6 +350,7 @@ class Settings(BaseSettings):
|
|||
SCRIPT_GENERATION_LLM_KEY: str | None = None
|
||||
SCRIPT_REVIEWER_LLM_KEY: str | None = None
|
||||
ADAPTIVE_SCRIPT_GEN_LLM_KEY: str | None = None
|
||||
WORKFLOW_COPILOT_LLM_KEY: str | None = None
|
||||
WORKFLOW_COPILOT_AGENT_LLM_KEY: str | None = None
|
||||
WORKFLOW_COPILOT_FAST_LLM_KEY: str | None = None
|
||||
# COMMON
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ class ForgeApp:
|
|||
SCRIPT_GENERATION_LLM_API_HANDLER: LLMAPIHandler
|
||||
SCRIPT_REVIEWER_LLM_API_HANDLER: LLMAPIHandler
|
||||
ADAPTIVE_SCRIPT_GEN_LLM_API_HANDLER: LLMAPIHandler
|
||||
WORKFLOW_COPILOT_LLM_API_HANDLER: LLMAPIHandler
|
||||
WORKFLOW_COPILOT_AGENT_LLM_API_HANDLER: LLMAPIHandler
|
||||
WORKFLOW_COPILOT_FAST_LLM_API_HANDLER: LLMAPIHandler
|
||||
WORKFLOW_CONTEXT_MANAGER: WorkflowContextManager
|
||||
|
|
@ -258,10 +259,15 @@ def create_forge_app() -> ForgeApp:
|
|||
if settings.ADAPTIVE_SCRIPT_GEN_LLM_KEY
|
||||
else app.LLM_API_HANDLER
|
||||
)
|
||||
app.WORKFLOW_COPILOT_LLM_API_HANDLER = (
|
||||
LLMAPIHandlerFactory.get_llm_api_handler(settings.WORKFLOW_COPILOT_LLM_KEY)
|
||||
if settings.WORKFLOW_COPILOT_LLM_KEY
|
||||
else app.LLM_API_HANDLER
|
||||
)
|
||||
app.WORKFLOW_COPILOT_AGENT_LLM_API_HANDLER = (
|
||||
LLMAPIHandlerFactory.get_llm_api_handler(settings.WORKFLOW_COPILOT_AGENT_LLM_KEY)
|
||||
if settings.WORKFLOW_COPILOT_AGENT_LLM_KEY
|
||||
else app.LLM_API_HANDLER
|
||||
else app.WORKFLOW_COPILOT_LLM_API_HANDLER
|
||||
)
|
||||
app.WORKFLOW_COPILOT_FAST_LLM_API_HANDLER = (
|
||||
LLMAPIHandlerFactory.get_llm_api_handler(settings.WORKFLOW_COPILOT_FAST_LLM_KEY)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from pydantic import ValidationError
|
|||
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.prompts import prompt_engine
|
||||
from skyvern.forge.sdk.copilot import llm_config
|
||||
from skyvern.forge.sdk.copilot.blocker_signal import (
|
||||
CopilotToolBlockerSignal,
|
||||
assert_clean_user_facing_text,
|
||||
|
|
@ -257,7 +258,17 @@ def _copilot_turn_span(
|
|||
yield span
|
||||
|
||||
|
||||
def _resolve_request_policy_handler(llm_api_handler: Any) -> Any:
|
||||
async def _resolve_request_policy_handler(
|
||||
llm_api_handler: LLMAPIHandler | None, workflow_permanent_id: str | None, organization_id: str | None
|
||||
) -> Any:
|
||||
lite_handler = await llm_config.resolve_lite_copilot_handler(workflow_permanent_id, organization_id)
|
||||
if lite_handler is not None:
|
||||
return lite_handler
|
||||
LOG.warning(
|
||||
"copilot request policy lite handler unavailable, falling back to main handler",
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
return llm_api_handler
|
||||
|
||||
|
||||
|
|
@ -269,7 +280,8 @@ class RequestPolicyGuardrailInputs:
|
|||
chat_history_messages: list[WorkflowCopilotChatHistoryMessage]
|
||||
global_llm_context: str
|
||||
organization_id: str
|
||||
handler: Any
|
||||
request_policy_handler: Any
|
||||
turn_intent_handler: LLMAPIHandler | None
|
||||
previous_user_message: str | None = None
|
||||
workflow_id: str | None = None
|
||||
workflow_permanent_id: str | None = None
|
||||
|
|
@ -3647,7 +3659,7 @@ def _build_copilot_input_guardrails(
|
|||
chat_history=policy_inputs.chat_history_messages,
|
||||
global_llm_context=policy_inputs.global_llm_context,
|
||||
organization_id=policy_inputs.organization_id,
|
||||
handler=policy_inputs.handler,
|
||||
handler=policy_inputs.request_policy_handler,
|
||||
active_criteria=_stored_active_completion_criteria(policy_inputs),
|
||||
config=getattr(ctx, "copilot_config", None) if isinstance(ctx, CopilotContext) else None,
|
||||
)
|
||||
|
|
@ -3660,7 +3672,7 @@ def _build_copilot_input_guardrails(
|
|||
chat_history=policy_inputs.chat_history_messages,
|
||||
global_llm_context=policy_inputs.global_llm_context,
|
||||
request_policy=policy,
|
||||
handler=policy_inputs.handler,
|
||||
handler=policy_inputs.turn_intent_handler,
|
||||
)
|
||||
_store_request_policy_on_context(
|
||||
ctx,
|
||||
|
|
@ -4128,7 +4140,12 @@ async def _run_copilot_turn_impl(
|
|||
chat_history_messages=list(chat_history),
|
||||
global_llm_context=safe_global_llm_context,
|
||||
organization_id=organization_id,
|
||||
handler=_resolve_request_policy_handler(llm_api_handler),
|
||||
request_policy_handler=await _resolve_request_policy_handler(
|
||||
llm_api_handler,
|
||||
chat_request.workflow_permanent_id,
|
||||
organization_id,
|
||||
),
|
||||
turn_intent_handler=llm_api_handler,
|
||||
previous_user_message=previous_user_message,
|
||||
workflow_id=chat_request.workflow_id,
|
||||
workflow_permanent_id=chat_request.workflow_permanent_id,
|
||||
|
|
|
|||
|
|
@ -2385,7 +2385,7 @@ def _uncovered_output_reject_rescout_key(canonical_paths: set[str], structural_f
|
|||
def _active_uncovered_output_reject_paths(ctx: AgentContext) -> set[str]:
|
||||
canonical = {
|
||||
_canonical_output_path(path)
|
||||
for path in author_time_reject_missing_output_paths(getattr(ctx, "latest_recorded_build_test_outcome", None))
|
||||
for path in author_time_reject_missing_output_paths(ctx.latest_recorded_build_test_outcome)
|
||||
}
|
||||
return canonical & uncovered_requested_output_paths(ctx) if canonical else set()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,20 @@ LOG = structlog.get_logger()
|
|||
|
||||
WORKFLOW_COPILOT_PROMPT_TYPE = "workflow-copilot"
|
||||
WORKFLOW_COPILOT_FAST_PROMPT_TYPE = "workflow-copilot-fast"
|
||||
WORKFLOW_COPILOT_LITE_PROMPT_TYPE = "workflow-copilot-lite"
|
||||
|
||||
|
||||
def get_workflow_copilot_handler() -> Any | None:
|
||||
try:
|
||||
handler = app.WORKFLOW_COPILOT_LLM_API_HANDLER
|
||||
except (RuntimeError, AttributeError):
|
||||
handler = None
|
||||
if handler is not None:
|
||||
return handler
|
||||
try:
|
||||
return app.LLM_API_HANDLER
|
||||
except (RuntimeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_main_copilot_handler() -> Any | None:
|
||||
|
|
@ -20,10 +34,7 @@ def get_main_copilot_handler() -> Any | None:
|
|||
handler = None
|
||||
if handler is not None:
|
||||
return handler
|
||||
try:
|
||||
return app.LLM_API_HANDLER
|
||||
except (RuntimeError, AttributeError):
|
||||
return None
|
||||
return get_workflow_copilot_handler()
|
||||
|
||||
|
||||
def get_fast_copilot_handler() -> Any | None:
|
||||
|
|
@ -53,6 +64,22 @@ async def resolve_main_copilot_handler(workflow_permanent_id: str | None, organi
|
|||
return get_main_copilot_handler()
|
||||
|
||||
|
||||
async def resolve_workflow_copilot_handler(
|
||||
workflow_permanent_id: str | None, organization_id: str | None
|
||||
) -> Any | None:
|
||||
if workflow_permanent_id and organization_id:
|
||||
try:
|
||||
posthog_handler = await get_llm_handler_for_prompt_type(
|
||||
WORKFLOW_COPILOT_PROMPT_TYPE, workflow_permanent_id, organization_id
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.warning("workflow copilot PostHog lookup failed, falling back", error=str(exc))
|
||||
posthog_handler = None
|
||||
if posthog_handler is not None:
|
||||
return posthog_handler
|
||||
return get_workflow_copilot_handler()
|
||||
|
||||
|
||||
async def resolve_fast_copilot_handler(workflow_permanent_id: str | None, organization_id: str | None) -> Any | None:
|
||||
if workflow_permanent_id and organization_id:
|
||||
try:
|
||||
|
|
@ -65,3 +92,17 @@ async def resolve_fast_copilot_handler(workflow_permanent_id: str | None, organi
|
|||
if posthog_handler is not None:
|
||||
return posthog_handler
|
||||
return get_fast_copilot_handler()
|
||||
|
||||
|
||||
async def resolve_lite_copilot_handler(workflow_permanent_id: str | None, organization_id: str | None) -> Any | None:
|
||||
if workflow_permanent_id and organization_id:
|
||||
try:
|
||||
posthog_handler = await get_llm_handler_for_prompt_type(
|
||||
WORKFLOW_COPILOT_LITE_PROMPT_TYPE, workflow_permanent_id, organization_id
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.warning("lite copilot PostHog lookup failed, falling back", error=str(exc))
|
||||
posthog_handler = None
|
||||
if posthog_handler is not None:
|
||||
return posthog_handler
|
||||
return await resolve_workflow_copilot_handler(workflow_permanent_id, organization_id)
|
||||
|
|
|
|||
|
|
@ -1574,7 +1574,7 @@ def _recorded_outcome_convergence_reject(
|
|||
return None
|
||||
if candidate_signature == latest.authored_structure_signature:
|
||||
return _ConvergenceReject(candidate_signature, "identical_authored_structure", False)
|
||||
constraint = ctx.recorded_outcome_binding_constraint
|
||||
constraint = getattr(ctx, "recorded_outcome_binding_constraint", None)
|
||||
if not isinstance(constraint, RecordedOutcomeBindingConstraint):
|
||||
return None
|
||||
# An author-time reject re-keys `latest` without an executed run, so keep the binding
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ LOG = structlog.get_logger()
|
|||
|
||||
async def get_llm_config_by_prompt_type(distinct_id: str, organization_id: str | None = None) -> dict[str, str] | None:
|
||||
"""Return PostHog-configured LLM mapping for each prompt type."""
|
||||
llm_config_experiment = await app.EXPERIMENTATION_PROVIDER.get_value(
|
||||
llm_config_experiment = await app.EXPERIMENTATION_PROVIDER.get_value_cached(
|
||||
"LLM_CONFIG_BY_PROMPT_TYPE", distinct_id, properties={"organization_id": organization_id}
|
||||
)
|
||||
if llm_config_experiment in (False, "False") or not llm_config_experiment:
|
||||
return None
|
||||
|
||||
payload = await app.EXPERIMENTATION_PROVIDER.get_payload(
|
||||
payload = await app.EXPERIMENTATION_PROVIDER.get_payload_cached(
|
||||
"LLM_CONFIG_BY_PROMPT_TYPE", distinct_id, properties={"organization_id": organization_id}
|
||||
)
|
||||
if not payload:
|
||||
|
|
|
|||
90
skyvern/forge/sdk/workflow/code_block_safety.py
Normal file
90
skyvern/forge/sdk/workflow/code_block_safety.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Shared static safety checks for CodeBlock Python snippets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import textwrap
|
||||
from collections.abc import Callable
|
||||
|
||||
from skyvern.forge.sdk.workflow.exceptions import InsecureCodeDetected
|
||||
|
||||
# Keep this policy aligned with codeblock/codeblock_safety.py; the runner image carries a local copy.
|
||||
BLOCKED_ATTRS: frozenset[str] = frozenset(
|
||||
{
|
||||
"create_subprocess_exec",
|
||||
"create_subprocess_shell",
|
||||
"system",
|
||||
"popen",
|
||||
"Popen",
|
||||
"exec",
|
||||
"spawn",
|
||||
"spawnl",
|
||||
"spawnle",
|
||||
"spawnlp",
|
||||
"spawnlpe",
|
||||
"check_call",
|
||||
"check_output",
|
||||
"execv",
|
||||
"execve",
|
||||
"execvp",
|
||||
"execvpe",
|
||||
"execl",
|
||||
"execlp",
|
||||
"execlpe",
|
||||
"fork",
|
||||
"open_connection",
|
||||
"start_server",
|
||||
"create_connection",
|
||||
"create_server",
|
||||
"f_globals",
|
||||
"f_locals",
|
||||
"f_builtins",
|
||||
"f_code",
|
||||
"co_code",
|
||||
"co_consts",
|
||||
"co_names",
|
||||
"co_varnames",
|
||||
"gi_frame",
|
||||
"gi_code",
|
||||
"cr_frame",
|
||||
"cr_code",
|
||||
"tb_frame",
|
||||
"tb_next",
|
||||
"mro",
|
||||
"listdir",
|
||||
"makedirs",
|
||||
"rmdir",
|
||||
"codecs",
|
||||
"modules",
|
||||
"builtins",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"stdin",
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
"globals",
|
||||
"eval",
|
||||
"vars",
|
||||
"format",
|
||||
"format_map",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_safe_code(
|
||||
code: str,
|
||||
*,
|
||||
error_factory: Callable[[str], Exception] = InsecureCodeDetected,
|
||||
) -> None:
|
||||
"""Reject imports, private members, and known escape hatches."""
|
||||
tree = ast.parse(textwrap.dedent(code))
|
||||
for node in ast.walk(tree):
|
||||
if hasattr(node, "attr") and str(node.attr).startswith("__"):
|
||||
raise error_factory("Not allowed to access private methods or attributes")
|
||||
if isinstance(node, ast.Name) and node.id.startswith("__"):
|
||||
raise error_factory("Not allowed to access private methods or attributes")
|
||||
if isinstance(node, ast.Import | ast.ImportFrom):
|
||||
raise error_factory("Not allowed to import modules")
|
||||
if hasattr(node, "attr") and node.attr in BLOCKED_ATTRS:
|
||||
raise error_factory(f"Not allowed to access '{node.attr}'")
|
||||
|
|
@ -109,6 +109,8 @@ from skyvern.forge.sdk.settings_manager import SettingsManager
|
|||
from skyvern.forge.sdk.trace import traced
|
||||
from skyvern.forge.sdk.utils.pdf_parser import extract_pdf_file, render_pdf_pages_as_images, validate_pdf_file
|
||||
from skyvern.forge.sdk.utils.sanitization import sanitize_postgres_text
|
||||
from skyvern.forge.sdk.workflow.code_block_safety import BLOCKED_ATTRS as CODE_BLOCK_BLOCKED_ATTRS
|
||||
from skyvern.forge.sdk.workflow.code_block_safety import is_safe_code as _shared_is_safe_code
|
||||
from skyvern.forge.sdk.workflow.constants import OUTPUT_PARAMETER_MAX_VALUE_BYTES
|
||||
from skyvern.forge.sdk.workflow.context_manager import BlockMetadata, WorkflowRunContext
|
||||
from skyvern.forge.sdk.workflow.exceptions import (
|
||||
|
|
@ -3647,98 +3649,11 @@ class CodeBlock(Block):
|
|||
prompt: str | None = None
|
||||
steps: list[CodeBlockStep] | None = None
|
||||
|
||||
# Dangerous attribute names that must never be accessed in user code.
|
||||
# This blocks subprocess creation, OS access, and sandbox-escape primitives.
|
||||
# NOTE: This is a blocklist-based sandbox, not real process-level isolation.
|
||||
# It is inherently incomplete — a determined attacker may find bypasses.
|
||||
# Long-term we should run user code in a proper sandbox. This blocklist is
|
||||
# a defense-in-depth layer, not a security boundary.
|
||||
# NOTE: Do not add names that collide with safe module methods (e.g. re.compile).
|
||||
# Builtin functions like compile(), eval(), exec() are already blocked via __builtins__: {}.
|
||||
BLOCKED_ATTRS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
# Subprocess / OS execution
|
||||
"create_subprocess_exec",
|
||||
"create_subprocess_shell",
|
||||
"system",
|
||||
"popen",
|
||||
"Popen",
|
||||
"exec",
|
||||
"spawn",
|
||||
"spawnl",
|
||||
"spawnle",
|
||||
"spawnlp",
|
||||
"spawnlpe",
|
||||
"check_call",
|
||||
"check_output",
|
||||
"execv",
|
||||
"execve",
|
||||
"execvp",
|
||||
"execvpe",
|
||||
"execl",
|
||||
"execlp",
|
||||
"execlpe",
|
||||
"fork",
|
||||
# Network primitives
|
||||
"open_connection",
|
||||
"start_server",
|
||||
"create_connection",
|
||||
"create_server",
|
||||
# Frame / code object internals (classic RestrictedPython escape vectors)
|
||||
"f_globals",
|
||||
"f_locals",
|
||||
"f_builtins",
|
||||
"f_code",
|
||||
"co_code",
|
||||
"co_consts",
|
||||
"co_names",
|
||||
"co_varnames",
|
||||
"gi_frame",
|
||||
"gi_code",
|
||||
"cr_frame",
|
||||
"cr_code",
|
||||
"tb_frame",
|
||||
"tb_next",
|
||||
# Class hierarchy escape
|
||||
"mro",
|
||||
# Filesystem operations (unambiguous — these only appear on os/pathlib, not user objects)
|
||||
"listdir",
|
||||
"makedirs",
|
||||
"rmdir",
|
||||
# Module traversal (json.codecs.sys.modules etc.)
|
||||
"codecs",
|
||||
"modules",
|
||||
"builtins",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"stdin",
|
||||
# Sandbox-escape helpers (builtin equivalents already blocked via __builtins__: {})
|
||||
"getattr",
|
||||
"setattr",
|
||||
"delattr",
|
||||
"globals",
|
||||
"eval",
|
||||
"vars",
|
||||
"format",
|
||||
"format_map",
|
||||
}
|
||||
)
|
||||
BLOCKED_ATTRS: ClassVar[frozenset[str]] = CODE_BLOCK_BLOCKED_ATTRS
|
||||
|
||||
@staticmethod
|
||||
def is_safe_code(code: str) -> None:
|
||||
tree = ast.parse(code)
|
||||
for node in ast.walk(tree):
|
||||
# Block dunder attribute access (obj.__foo__)
|
||||
if hasattr(node, "attr") and str(node.attr).startswith("__"):
|
||||
raise InsecureCodeDetected("Not allowed to access private methods or attributes")
|
||||
# Block bare dunder identifiers (__capture_locals, __builtins__, etc.)
|
||||
if isinstance(node, ast.Name) and node.id.startswith("__"):
|
||||
raise InsecureCodeDetected("Not allowed to access private methods or attributes")
|
||||
if isinstance(node, ast.Import) or isinstance(node, ast.ImportFrom):
|
||||
raise InsecureCodeDetected("Not allowed to import modules")
|
||||
# Block dangerous method/attribute access on any object
|
||||
if hasattr(node, "attr") and node.attr in CodeBlock.BLOCKED_ATTRS:
|
||||
raise InsecureCodeDetected(f"Not allowed to access '{node.attr}'")
|
||||
_shared_is_safe_code(code)
|
||||
|
||||
@staticmethod
|
||||
def build_safe_vars() -> dict[str, Any]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue