SKY-11498: Route request-policy classifier through lite Copilot model setting (#7191)

This commit is contained in:
Andrew Neilson 2026-07-07 21:20:33 -07:00 committed by GitHub
parent 95d1e966fa
commit b7c1383f81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 383 additions and 150 deletions

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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:

View 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}'")

View file

@ -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]: