mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
3267 lines
121 KiB
Python
3267 lines
121 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from agents import GuardrailFunctionOutput, OutputGuardrail, ToolInputGuardrailData
|
|
from agents.run_context import RunContextWrapper
|
|
from agents.tool_context import ToolContext
|
|
|
|
from skyvern.config import settings
|
|
from skyvern.forge.sdk.copilot import agent as agent_module
|
|
from skyvern.forge.sdk.copilot.blocker_signal import CopilotToolBlockerSignal
|
|
from skyvern.forge.sdk.copilot.build_phase import BuildPhase
|
|
from skyvern.forge.sdk.copilot.context import CopilotContext
|
|
from skyvern.forge.sdk.copilot.enforcement import (
|
|
PRESENT_COMPLETION_CONTRACT_ASK_RETRY,
|
|
_response_coverage_nudge,
|
|
)
|
|
from skyvern.forge.sdk.copilot.output_contracts import OUTPUT_SOURCE_UNOBSERVABLE_REASON_CODE
|
|
from skyvern.forge.sdk.copilot.output_policy import (
|
|
ACTUATION_OBLIGATION_BROWSER_ACTION_KEY,
|
|
ACTUATION_OBLIGATION_STEER_REASON_CODE,
|
|
ACTUATION_OBLIGATION_UNMET_REASON_CODE,
|
|
ActuationObligationStatus,
|
|
CannotActReason,
|
|
CopilotOutputKind,
|
|
OutputPolicyReason,
|
|
OutputPolicyVerdict,
|
|
_contains_internal_tool_vocab_leak,
|
|
derive_output_kind,
|
|
evaluate_actuation_obligation,
|
|
evaluate_output_policy,
|
|
hard_block_output_policy_verdict,
|
|
normalize_response_scaffolding,
|
|
)
|
|
from skyvern.forge.sdk.copilot.request_policy import CompletionCriterion, RequestPolicy
|
|
from skyvern.forge.sdk.copilot.tools import (
|
|
_WORKFLOW_YAML_OUTPUT_POLICY_GUARDRAIL,
|
|
NATIVE_TOOLS,
|
|
)
|
|
from skyvern.forge.sdk.copilot.turn_intent import RequiredContextKey, TurnIntent, TurnIntentAuthority, TurnIntentMode
|
|
from skyvern.forge.sdk.schemas.copilot_turn_outcome import ResponseKind, TurnOutcome
|
|
|
|
|
|
def _credential(credential_id: str = "cred_safe", tested_url: str = "https://login.example.test/login") -> object:
|
|
return SimpleNamespace(credential_id=credential_id, name="Saved Login", tested_url=tested_url)
|
|
|
|
|
|
def _policy(**overrides: object) -> RequestPolicy:
|
|
defaults = dict(resolved_credentials=[_credential()])
|
|
defaults.update(overrides)
|
|
return RequestPolicy(**defaults)
|
|
|
|
|
|
def _ctx(**overrides: object) -> CopilotContext:
|
|
defaults = dict(
|
|
organization_id="org-1",
|
|
workflow_id="wf-1",
|
|
workflow_permanent_id="wfp-1",
|
|
workflow_yaml="",
|
|
browser_session_id=None,
|
|
stream=MagicMock(),
|
|
request_policy=_policy(),
|
|
)
|
|
defaults.update(overrides)
|
|
return CopilotContext(**defaults)
|
|
|
|
|
|
def _browser_actuation_intent() -> TurnIntent:
|
|
return TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
required_context=[RequiredContextKey.BROWSER_STATE],
|
|
authority=TurnIntentAuthority(may_update_workflow=False, may_run_blocks=False),
|
|
)
|
|
|
|
|
|
def _live_fill_policy() -> RequestPolicy:
|
|
return _policy(
|
|
completion_criteria=[
|
|
CompletionCriterion(
|
|
id="c0",
|
|
outcome="The form is completed.",
|
|
kind="terminal_action",
|
|
terminal_action_family="form",
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
def _unknown_browser_actuation_intent() -> TurnIntent:
|
|
return TurnIntent(
|
|
mode=TurnIntentMode.UNKNOWN,
|
|
required_context=[RequiredContextKey.BROWSER_STATE],
|
|
authority=TurnIntentAuthority(may_update_workflow=False, may_run_blocks=False),
|
|
)
|
|
|
|
|
|
def _browser_actuation_intent_without_browser_state(*, mode: TurnIntentMode) -> TurnIntent:
|
|
return TurnIntent(
|
|
mode=mode,
|
|
required_context=[],
|
|
authority=TurnIntentAuthority(may_update_workflow=False, may_run_blocks=False),
|
|
)
|
|
|
|
|
|
def _fake_run_result(payload: dict) -> SimpleNamespace:
|
|
return SimpleNamespace(final_output=json.dumps(payload), new_items=[])
|
|
|
|
|
|
def _chat_request() -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
workflow_id="wf-1",
|
|
workflow_permanent_id="wfp-1",
|
|
workflow_copilot_chat_id="chat-1",
|
|
workflow_yaml="title: Prior",
|
|
)
|
|
|
|
|
|
def _workflow_yaml(
|
|
*,
|
|
url: str = "https://login.example.test/login",
|
|
navigation_goal: str = "Log in.",
|
|
) -> str:
|
|
return f"""
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: login_credentials
|
|
default_value: cred_safe
|
|
blocks:
|
|
- block_type: login
|
|
label: login
|
|
url: {url}
|
|
navigation_goal: {navigation_goal}
|
|
parameter_keys:
|
|
- login_credentials
|
|
"""
|
|
|
|
|
|
def _code_artifact_metadata(label: str = "open_results") -> list[dict[str, object]]:
|
|
return [
|
|
{
|
|
"block_label": label,
|
|
"declared_goal": "Open the result page.",
|
|
"claimed_outcomes": [
|
|
{
|
|
"id": "claim:open_results",
|
|
"scope": "sufficient_for_prefix_validation",
|
|
"text": "The result page is reachable.",
|
|
"status": "satisfied",
|
|
"depends_on": ["dep:result_link"],
|
|
"criteria_ids": ["criterion:result_page_reached"],
|
|
"evidence_refs": ["evidence:result_link"],
|
|
}
|
|
],
|
|
"page_dependencies": [
|
|
{
|
|
"id": "dep:result_link",
|
|
"scope": "current_page_state",
|
|
"status": "satisfied",
|
|
"evidence_refs": ["evidence:result_link"],
|
|
}
|
|
],
|
|
"completion_criteria": [
|
|
{
|
|
"id": "criterion:result_page_reached",
|
|
"text": "The result page is visible after authored execution.",
|
|
"level": "terminal",
|
|
}
|
|
],
|
|
"evidence_refs": [
|
|
{
|
|
"evidence_ref": "evidence:result_link",
|
|
"claim_id": "claim:open_results",
|
|
"dependency_id": "dep:result_link",
|
|
"criterion_id": "criterion:result_page_reached",
|
|
"status": "satisfied",
|
|
"source_tool": "inspect_page_for_composition",
|
|
"observation_step": 1,
|
|
}
|
|
],
|
|
"observation_refs": [],
|
|
"terminal_verifier_expectations": [
|
|
{
|
|
"id": "verify:result_page_reached",
|
|
"text": "Verify the result page is visible.",
|
|
"criteria_ids": ["criterion:result_page_reached"],
|
|
"claimed_outcome_ids": ["claim:open_results"],
|
|
}
|
|
],
|
|
"exploration_observations": [],
|
|
}
|
|
]
|
|
|
|
|
|
def _inline_conditional_workflow_yaml(*, url: str = "https://login.example.test/login") -> str:
|
|
return f"""
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: login_credentials
|
|
default_value: cred_safe
|
|
blocks:
|
|
- block_type: conditional
|
|
label: route_login
|
|
branch_conditions:
|
|
- is_default: true
|
|
blocks:
|
|
- block_type: login
|
|
label: login
|
|
url: {url}
|
|
parameter_keys:
|
|
- login_credentials
|
|
"""
|
|
|
|
|
|
def _nested_branch_workflow_yaml(*, url: str = "https://login.example.test/login") -> str:
|
|
return f"""
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: login_credentials
|
|
default_value: cred_safe
|
|
blocks:
|
|
- block_type: conditional
|
|
label: route_login
|
|
branch_conditions:
|
|
- is_default: true
|
|
branch_conditions:
|
|
- is_default: true
|
|
blocks:
|
|
- block_type: login
|
|
label: nested_login
|
|
url: {url}
|
|
parameter_keys:
|
|
- login_credentials
|
|
"""
|
|
|
|
|
|
def test_rejects_raw_secret_echo_in_user_response() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
user_response="I used password: hunter2 to test the login.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
|
|
def test_output_policy_still_blocks_with_author_time_log_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, "ENV", "local")
|
|
monkeypatch.setattr(settings, "WORKFLOW_COPILOT_AUTHOR_TIME_GATE_LOG_ONLY", True)
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
user_response="I used password: hunter2 to test the login.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("response_type", "user_response", "expected_type", "expected_response"),
|
|
[
|
|
("REPLY", "ASK_QUESTION\nWhich account should I use?", "ASK_QUESTION", "Which account should I use?"),
|
|
("REPLY", " ASK_QUESTION\nWhich account should I use?", "ASK_QUESTION", "Which account should I use?"),
|
|
("REPLY", "ASK_QUESTION Which account should I use?", "ASK_QUESTION", "Which account should I use?"),
|
|
("REPLY", "ask_question\nWhich account should I use?", "ASK_QUESTION", "Which account should I use?"),
|
|
("REPLY", "Ask_Question Which account should I use?", "ASK_QUESTION", "Which account should I use?"),
|
|
("REPLY", "REPLY\nI can help with that.", "REPLY", "I can help with that."),
|
|
("REPLY", "reply\nI can help with that.", "REPLY", "I can help with that."),
|
|
("REPLY", "REPLACE_WORKFLOW\nI updated the workflow.", "REPLY", "I updated the workflow."),
|
|
("REPLY", "REPLACE_WORKFLOW I updated the workflow.", "REPLY", "I updated the workflow."),
|
|
(
|
|
"REPLACE_WORKFLOW",
|
|
"REPLACE_WORKFLOW\nI updated the workflow.",
|
|
"REPLACE_WORKFLOW",
|
|
"I updated the workflow.",
|
|
),
|
|
],
|
|
)
|
|
def test_normalizes_plain_internal_response_label_scaffolding(
|
|
response_type: str,
|
|
user_response: str,
|
|
expected_type: str,
|
|
expected_response: str,
|
|
) -> None:
|
|
normalized = normalize_response_scaffolding(response_type, user_response)
|
|
|
|
assert normalized.changed
|
|
assert normalized.response_type == expected_type
|
|
assert normalized.user_response == expected_response
|
|
|
|
|
|
def test_normalize_scaffolding_preserves_ordinary_reply_sentence() -> None:
|
|
text = "Reply with the invoice number from the page."
|
|
normalized = normalize_response_scaffolding("REPLY", text)
|
|
|
|
assert not normalized.changed
|
|
assert normalized.response_type == "REPLY"
|
|
assert normalized.user_response == text
|
|
|
|
|
|
def test_normalize_scaffolding_preserves_wrapped_ordinary_reply_sentence() -> None:
|
|
text = "Reply with\nthe invoice number from the page."
|
|
normalized = normalize_response_scaffolding("REPLY", text)
|
|
|
|
assert not normalized.changed
|
|
assert normalized.response_type == "REPLY"
|
|
assert normalized.user_response == text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
pytest.param(
|
|
"Next step: call get_run_results with this workflow_run_id.",
|
|
id="call-get-run-results-instruction",
|
|
),
|
|
pytest.param(
|
|
'Call `get_run_results(workflow_run_id="wr_123")` first, then await user input.',
|
|
id="call-get-run-results-backtick-await",
|
|
),
|
|
pytest.param(
|
|
"Then call update_and_run_blocks with a smaller chain.",
|
|
id="call-update-and-run-blocks-instruction",
|
|
),
|
|
pytest.param(
|
|
"Do NOT retry this tool call; wait for the current run result.",
|
|
id="do-not-retry-tool-call",
|
|
),
|
|
pytest.param(
|
|
"Do NOT re-invoke the tool until the user responds.",
|
|
id="do-not-reinvoke-tool",
|
|
),
|
|
pytest.param(
|
|
'I\'ll call get_run_results(workflow_run_id="wr_123") and report back.',
|
|
id="call-get-run-results-report-back",
|
|
),
|
|
pytest.param(
|
|
"Next I need to run_blocks_and_collect_debug[block_labels=['login']].",
|
|
id="run-blocks-and-collect-debug",
|
|
),
|
|
pytest.param(
|
|
"Use `update_workflow` to apply that change.",
|
|
id="backtick-update-workflow-tool-name",
|
|
),
|
|
pytest.param(
|
|
"Trace shows get_run_results was the last tool dispatched on this turn.",
|
|
id="trace-mentions-get-run-results",
|
|
),
|
|
pytest.param(
|
|
"Falling back to list_credentials since the user did not specify one.",
|
|
id="list-credentials-fallback",
|
|
),
|
|
pytest.param(
|
|
"Less than 90 seconds remain in this Copilot turn after the previous workflow run failed. "
|
|
"Do NOT retry block-running tools.",
|
|
id="late-block-running-timer-do-not-retry",
|
|
),
|
|
],
|
|
)
|
|
def test_rejects_internal_tool_instruction_leak_in_user_response(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.INTERNAL_TOOL_INSTRUCTION_LEAK in verdict.reason_codes
|
|
assert OutputPolicyReason.INTERNAL_TOOL_INSTRUCTION_LEAK in hard_block_output_policy_verdict(verdict).reason_codes
|
|
|
|
|
|
def test_allows_benign_do_not_retry_user_guidance() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="Do not retry until the account lockout clears.",
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.INTERNAL_TOOL_INSTRUCTION_LEAK not in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"I used hunter2 as the password.",
|
|
"Type hunter2 into the password field.",
|
|
],
|
|
)
|
|
def test_does_not_infer_arbitrary_prose_secret_echoes(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_rejects_raw_secret_in_workflow_yaml() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=_workflow_yaml(navigation_goal="Log in with password: hunter2."),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_raw_secret_in_structured_tool_arguments() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": {"navigation_goal": "Type password: hunter2 into the field."}},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
|
|
class TestSanctionedSecretReferenceIdiom:
|
|
"""`password = parameters[...]` / attribute-chain reads are references to bound
|
|
values, not leaks — the code-only authoring idiom must pass the syntactic
|
|
backstop while literal values keep blocking."""
|
|
|
|
def test_allows_parameters_subscript_assignment_in_tool_arguments(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": 'code: |\n password = parameters["login_credential"]'},
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
def test_allows_credential_attribute_read_in_workflow_yaml(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=(
|
|
"code: |\n"
|
|
" username = login_credential.username\n"
|
|
" password = login_credential.password\n"
|
|
' await page.locator("#passwordInput").fill(login_credential.password)\n'
|
|
),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
def test_allows_awaited_credential_otp_assignment_in_workflow_yaml(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=(
|
|
'code: |\n passcode = await login_credentials.otp()\n await page.locator("#code").fill(passcode)\n'
|
|
),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
def test_allows_keyword_secret_reference_to_awaited_credential_otp_in_tool_arguments(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": "code: |\n token = await login_credential.otp()"},
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
def test_rejects_literal_secret_even_when_same_line_has_credential_reference(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={
|
|
"workflow_yaml": "code: |\n password = 'hunter2'; password_ref = login_credential.password"
|
|
},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_allows_str_wrapped_parameter_reference(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": 'code: |\n token = str(parameters.get("api_token"))'},
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
def test_still_rejects_literal_appended_to_reference(self) -> None:
|
|
for rhs in (
|
|
'login_credential.password+"hunter2"',
|
|
'str(login_credential.password)+"hunter2"',
|
|
'parameters["cred"]or"hunter2"',
|
|
):
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": f"code: |\n password = {rhs}"},
|
|
)
|
|
|
|
assert not verdict.allowed, rhs
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_still_rejects_awaited_non_credential_secret_source(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": "code: |\n passcode = await page.locator('#code').inner_text()"},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_allows_keyword_argument_reference_with_closing_paren(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": 'code: |\n await do_login(password=parameters["cred"])'},
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
def test_still_rejects_jwt_shaped_literal_assignment(self) -> None:
|
|
# A dotted literal (JWT-shaped) must not pass as an "attribute chain".
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": "code: |\n token = eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.sig"},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_still_rejects_dotted_literal_not_ending_in_credential_field(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": "code: |\n password = admin.password123"},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_still_rejects_quoted_literal_assignment(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": 'code: |\n password = "hunter2"'},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_still_rejects_bare_literal_assignment(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": "password: hunter2"},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_still_rejects_email_password_pair_next_to_reference_idiom(self) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={
|
|
"workflow_yaml": 'code: |\n password = parameters["cred"]\nnotes: qa.user@example.test:FakePass123!'
|
|
},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
def test_chat_surface_request_policy_detection_is_unchanged(self) -> None:
|
|
# The pre-agent chat-surface detector stays conservative: even the code
|
|
# idiom pasted into chat still counts as a raw-secret signal there.
|
|
from skyvern.forge.sdk.copilot.request_policy import _raw_secret_detected
|
|
|
|
assert _raw_secret_detected("password: hunter2")
|
|
assert _raw_secret_detected('password = parameters["cred"]')
|
|
|
|
|
|
def test_rejects_bulk_colon_delimited_credentials_in_tool_arguments() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={
|
|
"parameters": {"account_list": "alpha@example.test:FakePass123!\nbeta@example.test:AnotherFakePass456!"}
|
|
},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
|
|
def test_allows_scp_style_paths_and_url_ports_in_tool_arguments() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={
|
|
"repository": "git@github.com:skyvern-ai/skyvern.git",
|
|
"local_url": "https://qa.user@example.test:8080?org=1",
|
|
},
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_does_not_hard_block_on_prior_global_context_secret() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
user_response="I drafted a safe workflow.",
|
|
global_llm_context='{"prior_note": "User previously pasted password: hunter2."}',
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_rejects_raw_secret_even_when_workflow_contains_jinja_placeholder() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml="navigation_goal: Use {{ parameters.username }} and password: hunter2.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
|
|
|
|
def test_allows_sensitive_jinja_placeholder_in_navigation_goal() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=_workflow_yaml(
|
|
navigation_goal="Type {{ parameters.password }} into the password field.",
|
|
),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_rejects_reply_when_request_policy_required_clarification() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(user_response_policy="ask_clarification", allow_update_workflow=False),
|
|
response_type="REPLY",
|
|
user_response="I created the workflow with the credential.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.REQUEST_POLICY_CLARIFICATION_BYPASS in verdict.reason_codes
|
|
|
|
|
|
def test_request_policy_clarification_still_blocks_with_author_time_log_only(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(settings, "ENV", "local")
|
|
monkeypatch.setattr(settings, "WORKFLOW_COPILOT_AUTHOR_TIME_GATE_LOG_ONLY", True)
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(user_response_policy="ask_clarification", allow_update_workflow=False),
|
|
response_type="REPLY",
|
|
user_response="I created the workflow with the credential.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.REQUEST_POLICY_CLARIFICATION_BYPASS in verdict.reason_codes
|
|
|
|
|
|
def test_avoidable_output_field_confirmation_is_hard_blocked() -> None:
|
|
policy = _policy(
|
|
user_response_policy="proceed",
|
|
completion_contract_status="present",
|
|
completion_criteria=[
|
|
SimpleNamespace(id="record_identity", outcome="The returned record identifies the target record."),
|
|
],
|
|
)
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=policy,
|
|
response_type="ASK_QUESTION",
|
|
user_response="Please confirm the output fields before I build this record status workflow.",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=False,
|
|
)
|
|
hard = hard_block_output_policy_verdict(verdict)
|
|
|
|
assert OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION in verdict.reason_codes
|
|
assert hard.reason_codes == [OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION]
|
|
|
|
|
|
def test_output_field_confirmation_allowed_when_request_policy_requires_clarification() -> None:
|
|
policy = _policy(
|
|
user_response_policy="ask_clarification",
|
|
clarification_question="Which saved credential should I use?",
|
|
clarification_reason="credential_name_unresolved",
|
|
completion_contract_status="present",
|
|
completion_criteria=[
|
|
SimpleNamespace(id="record_identity", outcome="The returned record identifies the target record."),
|
|
],
|
|
)
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=policy,
|
|
response_type="ASK_QUESTION",
|
|
user_response="Which saved credential should I use?",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=False,
|
|
)
|
|
|
|
assert OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION not in verdict.reason_codes
|
|
assert verdict.allowed is True
|
|
|
|
|
|
def test_url_clarification_does_not_trigger_output_field_confirmation_guard() -> None:
|
|
policy = _policy(user_response_policy="proceed", completion_contract_status="present")
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=policy,
|
|
response_type="ASK_QUESTION",
|
|
user_response="Which URL should I use for this workflow?",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=False,
|
|
)
|
|
|
|
assert OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION not in verdict.reason_codes
|
|
|
|
|
|
def test_classifies_unbacked_workflow_delivery_claim() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="Here's the workflow.",
|
|
has_workflow_proposal=False,
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNBACKED_WORKFLOW_DELIVERY_CLAIM in verdict.reason_codes
|
|
assert OutputPolicyReason.MISSING_PROPOSAL_STATE in verdict.reason_codes
|
|
|
|
|
|
def test_flags_block_yaml_pasted_into_user_response() -> None:
|
|
user_response = (
|
|
"I've now updated the workflow to also accept the form's URL as a parameter, named `form_url`. "
|
|
"Here's how the block now looks:\n\n"
|
|
" - label: navigate_and_fill_form\n"
|
|
" block_type: navigation\n"
|
|
" navigation_goal: Fill the abuse form using the supplied data.\n"
|
|
' url: "{{ form_url }}"\n'
|
|
" parameter_keys:\n"
|
|
" - name\n"
|
|
" - email\n"
|
|
" - form_url\n"
|
|
)
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
has_workflow_proposal=False,
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.WORKFLOW_YAML_IN_REPLY in verdict.reason_codes
|
|
|
|
|
|
def test_block_yaml_in_reply_is_not_hard_blocking() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="ASK_QUESTION",
|
|
user_response=(
|
|
"Here is the change I'd make:\n\n"
|
|
"```yaml\n"
|
|
"block_type: navigation\n"
|
|
"navigation_goal: Submit the form.\n"
|
|
"label: submit_form\n"
|
|
"```\n"
|
|
),
|
|
has_workflow_proposal=False,
|
|
)
|
|
|
|
assert OutputPolicyReason.WORKFLOW_YAML_IN_REPLY in verdict.reason_codes
|
|
hard = hard_block_output_policy_verdict(verdict)
|
|
assert OutputPolicyReason.WORKFLOW_YAML_IN_REPLY not in hard.reason_codes
|
|
|
|
|
|
def test_allows_workflow_delivery_language_after_failed_workflow_attempt() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="I created a draft workflow and tested it, but the test failed.",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=True,
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_classifies_missing_unvalidated_proposal_affordance() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="I drafted the workflow but did not finish testing.",
|
|
has_workflow_proposal=True,
|
|
unvalidated=True,
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.MISSING_UNVALIDATED_PROPOSAL_AFFORDANCE in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_incidental_accept_discard_language_for_unvalidated_proposal() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="I accept that the test failed, but I can't discard the login block without more info.",
|
|
has_workflow_proposal=True,
|
|
unvalidated=True,
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.MISSING_UNVALIDATED_PROPOSAL_AFFORDANCE in verdict.reason_codes
|
|
|
|
|
|
def test_allows_unvalidated_proposal_affordance_copy() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=(
|
|
"Use Review to inspect it, Accept to save it, or Reject to discard it. "
|
|
"It has not been tested or verified end-to-end."
|
|
),
|
|
has_workflow_proposal=True,
|
|
unvalidated=True,
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_allows_backend_authored_request_policy_clarification() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(user_response_policy="ask_clarification", allow_update_workflow=False),
|
|
response_type="ASK_QUESTION",
|
|
user_response="Which saved credential should I use?",
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert verdict.output_kind == CopilotOutputKind.CLARIFICATION_REQUEST
|
|
|
|
|
|
def test_rejects_deprecated_block_taxonomy_in_final_text() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=(
|
|
"`task_v2` is deprecated. Use these newer block types instead: "
|
|
"`navigation`, `extraction`, `validation`, `login`, `goto_url`, and `file_download`."
|
|
),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize("deprecated_identifier", ["task_v2", "Task_V2", "task-v2", "task v2", "taskv2"])
|
|
def test_rejects_deprecated_block_identifier_outside_informational_answer(deprecated_identifier: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.WORKFLOW_DRAFT_PROPOSAL,
|
|
user_response=f"The draft still contains `{deprecated_identifier}` from the older workflow.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_informational_block_taxonomy_list_without_deprecated_name() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=(
|
|
"Use these block types: navigation for page actions, extraction for data, validation for checks, "
|
|
"and goto_url for direct URLs."
|
|
),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK in verdict.reason_codes
|
|
|
|
|
|
def test_allows_two_internal_block_type_terms_in_informational_answer() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="Use a navigation block for the page action and an extraction block for the final data.",
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_allows_generic_navigation_validation_extraction_prose() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=(
|
|
"After login, the workflow uses navigation to reach the form, validation of the input, and "
|
|
"extraction of the resulting data."
|
|
),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_allows_taxonomy_terms_outside_informational_answer_without_deprecated_identifier() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.WORKFLOW_DRAFT_PROPOSAL,
|
|
user_response=(
|
|
"The draft includes navigation for page actions, extraction for data, validation for checks, "
|
|
"and goto_url for direct URLs."
|
|
),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_allows_single_task_block_reference_without_deprecated_identifier() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=(
|
|
"For that page action, use a navigation step as the task block and describe what the browser should do."
|
|
),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK not in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"TurnIntent classified this turn as `edit`, but we couldn't proceed. safe_reason_code=turn_intent_no_mutation_run_blocked.",
|
|
"Skipping: safe_reason_code=request_policy_clarification this turn.",
|
|
"Blocked by `TurnIntent`: please rephrase.",
|
|
"Hit turn_intent_unresolved_edit_target while routing.",
|
|
"RequestPolicy blocked this turn for clarification.",
|
|
"TurnIntent requires clarification before proceeding.",
|
|
"Trace shows safe_reason_code = turn_intent_no_mutation_run_blocked at exit.",
|
|
"Final safe_reason_code : request_policy_clarification reported.",
|
|
],
|
|
)
|
|
def test_rejects_internal_classifier_vocab_leak(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.INTERNAL_CLASSIFIER_VOCAB_LEAK in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"The request policy resolved your saved credential automatically.",
|
|
"Use a turn-by-turn approach to walk through each step.",
|
|
"I'll classify the turn as edit if you confirm.",
|
|
"Need turnintent context? Tell me what you want to change.",
|
|
],
|
|
)
|
|
def test_allows_benign_prose_referencing_classifier_terms(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.INTERNAL_CLASSIFIER_VOCAB_LEAK not in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"Saw [copilot:nudge] in the trace before the failure.",
|
|
"[copilot:screenshot] context truncated to keep this prompt small.",
|
|
"LOOP DETECTED: same tool dispatched three times in a row.",
|
|
"Loop Detected: same tool dispatched three times in a row.",
|
|
"loop detected: same tool dispatched three times in a row.",
|
|
"[Copilot:nudge] surfaced in the trace before the failure.",
|
|
"[COPILOT:NUDGE] surfaced in the trace before the failure.",
|
|
"Couldn't finish the diagnostic step on this nudge turn.",
|
|
],
|
|
)
|
|
def test_rejects_internal_machinery_vocab_via_extended_taxonomy(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"I'll get the run results for you once it finishes.",
|
|
"Update the workflow to log in before extracting data.",
|
|
"List your saved credentials in the Credentials UI.",
|
|
"I'll take a screenshot once the page loads.",
|
|
"Nudge me if you want a different approach.",
|
|
],
|
|
)
|
|
def test_allows_benign_prose_referencing_tool_names(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK not in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"Use `update_workflow` to apply that change.",
|
|
"LOOP DETECTED: same tool dispatched three times in a row.",
|
|
"Couldn't finish the diagnostic step on this nudge turn.",
|
|
"Saw [copilot:nudge] in the trace before the failure.",
|
|
"Trace shows get_run_results was the last tool dispatched on this turn.",
|
|
],
|
|
)
|
|
def test_extended_taxonomy_leak_skipped_on_ask_question(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="ASK_QUESTION",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK not in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"output_kind",
|
|
[
|
|
CopilotOutputKind.WORKFLOW_DRAFT_PROPOSAL,
|
|
CopilotOutputKind.WORKFLOW_UPDATE_PROPOSAL,
|
|
CopilotOutputKind.WORKFLOW_RUN_RESULT,
|
|
],
|
|
)
|
|
def test_tool_name_leak_hard_blocks_across_output_kinds(
|
|
output_kind: CopilotOutputKind,
|
|
) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="Use `update_workflow` to apply that change.",
|
|
output_kind=output_kind,
|
|
)
|
|
|
|
assert OutputPolicyReason.INTERNAL_TOOL_INSTRUCTION_LEAK in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"Send me a normal instruction like 'run it', and I'll continue.",
|
|
"Reply 'yes' to proceed.",
|
|
"Please send 'continue debugging' and I'll keep going.",
|
|
"Type 'cancel' to stop the workflow.",
|
|
"Kindly type 'cancel' to abort the current draft.",
|
|
" Reply 'yes' to proceed.",
|
|
"\tReply 'yes' to proceed.",
|
|
],
|
|
)
|
|
def test_rejects_self_prescriptive_phrase(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.SELF_PRESCRIPTIVE_PHRASE_LEAK in verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response",
|
|
[
|
|
"I need the value for 'account_id' to continue.",
|
|
"The user can reply 'yes' to confirm.",
|
|
"I'll respond once I have 'account_id'.",
|
|
"I'll save the workflow once you confirm.",
|
|
"Please confirm whether to continue.",
|
|
"I have a draft workflow proposal. I need this before I can build and test it: which credential should I use?",
|
|
"Send me what you'd like changed and I'll update the workflow.",
|
|
"Reply once you've reviewed the draft.",
|
|
"Type the field name where you'd like the value populated.",
|
|
"Respond once you've decided which credential to use.",
|
|
'Type "navigate" in the action field of the block.',
|
|
'Send "data" as JSON to the endpoint.',
|
|
'Reply objects include a status field like "success" or "error".',
|
|
"Reply 2's text is just a numeric ordinal, not a quote.",
|
|
],
|
|
)
|
|
def test_allows_benign_prose_around_quoted_examples(user_response: str) -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response=user_response,
|
|
)
|
|
|
|
assert OutputPolicyReason.SELF_PRESCRIPTIVE_PHRASE_LEAK not in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_unapproved_credential_id_in_final_text() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
user_response="I used cred_other for the login.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_credential_id_when_request_policy_approved_no_credentials() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(resolved_credentials=[]),
|
|
user_response="I used cred_some_existing for the login.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_allows_existing_workflow_credential_id_on_unrelated_turn() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
existing_workflow_credential_ids=["cred_safe"],
|
|
existing_workflow_credential_origins={"cred_safe": ["https://login.example.test"]},
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_workflow_yaml(navigation_goal="Open the reports page."),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE not in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_existing_workflow_credential_id_on_new_origin() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
existing_workflow_credential_ids=["cred_safe"],
|
|
existing_workflow_credential_origins={"cred_safe": ["https://login.example.test"]},
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_workflow_yaml(url="https://evil.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_existing_workflow_credential_id_on_inline_conditional_branch_new_origin() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
existing_workflow_credential_ids=["cred_safe"],
|
|
existing_workflow_credential_origins={"cred_safe": ["https://login.example.test"]},
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_inline_conditional_workflow_yaml(url="https://evil.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_existing_workflow_credential_id_on_nested_branch_new_origin() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
existing_workflow_credential_ids=["cred_safe"],
|
|
existing_workflow_credential_origins={"cred_safe": ["https://login.example.test"]},
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_nested_branch_workflow_yaml(url="https://evil.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_existing_workflow_credential_id_without_prior_origin_scope() -> None:
|
|
# Existing workflow credentials with no tracked URL scope cannot safely
|
|
# authorize later edits that introduce a credentialed URL.
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
existing_workflow_credential_ids=["cred_safe"],
|
|
existing_workflow_credential_origins={},
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_workflow_yaml(url="https://login.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_unapproved_credential_id_in_structured_tool_arguments() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
tool_arguments={"workflow_yaml": {"parameters": [{"default_value": "cred_other"}]}},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_allows_explicit_unresolved_credential_id_for_untested_draft() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
credential_input_kind="credential_id",
|
|
credential_refs=["cred_missing"],
|
|
invalid_credential_ids=["cred_missing"],
|
|
allow_missing_credentials_in_draft=True,
|
|
allow_run_blocks=False,
|
|
),
|
|
workflow_yaml=_workflow_yaml().replace("cred_safe", "cred_missing"),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_rejects_unrequested_credential_id_even_when_untested_draft_allows_missing_credentials() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
credential_input_kind="credential_name",
|
|
credential_refs=["Missing Login"],
|
|
allow_missing_credentials_in_draft=True,
|
|
allow_run_blocks=False,
|
|
),
|
|
workflow_yaml=_workflow_yaml().replace("cred_safe", "cred_other"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_allows_approved_credential_id_in_workflow_yaml() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=_workflow_yaml(),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_rejects_approved_credential_on_different_login_origin() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=_workflow_yaml(url="https://evil.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_credential_scope_still_blocks_with_author_time_log_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, "ENV", "local")
|
|
monkeypatch.setattr(settings, "WORKFLOW_COPILOT_AUTHOR_TIME_GATE_LOG_ONLY", True)
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=_workflow_yaml(url="https://evil.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_approved_credential_on_templated_login_origin() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
workflow_yaml=_workflow_yaml(url='"{{ parameters.target_url }}"'),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def test_derive_output_kind_uses_state_over_response_type() -> None:
|
|
assert (
|
|
derive_output_kind(
|
|
response_type="REPLY",
|
|
request_policy=_policy(user_response_policy="ask_clarification"),
|
|
updated_workflow=SimpleNamespace(),
|
|
workflow_was_persisted=True,
|
|
workflow_attempted=True,
|
|
unvalidated=False,
|
|
)
|
|
== CopilotOutputKind.CLARIFICATION_REQUEST
|
|
)
|
|
assert (
|
|
derive_output_kind(
|
|
response_type="REPLY",
|
|
request_policy=_policy(),
|
|
updated_workflow=SimpleNamespace(),
|
|
workflow_was_persisted=False,
|
|
workflow_attempted=False,
|
|
unvalidated=True,
|
|
)
|
|
== CopilotOutputKind.WORKFLOW_DRAFT_PROPOSAL
|
|
)
|
|
assert (
|
|
derive_output_kind(
|
|
response_type="REPLY",
|
|
request_policy=_policy(),
|
|
updated_workflow=SimpleNamespace(),
|
|
workflow_was_persisted=True,
|
|
workflow_attempted=True,
|
|
unvalidated=False,
|
|
)
|
|
== CopilotOutputKind.WORKFLOW_RUN_RESULT
|
|
)
|
|
assert (
|
|
derive_output_kind(
|
|
response_type="REPLY",
|
|
request_policy=_policy(),
|
|
updated_workflow=None,
|
|
workflow_was_persisted=True,
|
|
workflow_attempted=False,
|
|
unvalidated=False,
|
|
)
|
|
== CopilotOutputKind.INFORMATIONAL_ANSWER
|
|
)
|
|
|
|
|
|
def test_plain_reply_after_prior_persist_stays_informational() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="I can help with that.",
|
|
has_workflow_proposal=False,
|
|
workflow_was_persisted=True,
|
|
workflow_attempted=False,
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert verdict.output_kind == CopilotOutputKind.INFORMATIONAL_ANSWER
|
|
|
|
|
|
def test_persistence_state_mismatch_is_a_hard_block() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
user_response="I updated the workflow.",
|
|
workflow_was_persisted=False,
|
|
has_workflow_proposal=True,
|
|
output_kind=CopilotOutputKind.WORKFLOW_UPDATE_PROPOSAL,
|
|
)
|
|
hard_verdict = hard_block_output_policy_verdict(verdict)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.PERSISTENCE_STATE_MISMATCH in verdict.reason_codes
|
|
assert not hard_verdict.allowed
|
|
assert hard_verdict.reason_codes == [OutputPolicyReason.PERSISTENCE_STATE_MISMATCH]
|
|
|
|
|
|
def test_output_policy_verdict_reason_codes_force_blocked_state() -> None:
|
|
verdict = OutputPolicyVerdict(
|
|
allowed=True,
|
|
reason_codes=[OutputPolicyReason.REQUEST_POLICY_CLARIFICATION_BYPASS],
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
|
|
|
|
def test_sdk_agent_guardrail_builders_use_policy_names() -> None:
|
|
from agents import GuardrailFunctionOutput, InputGuardrail, OutputGuardrail
|
|
|
|
input_guardrails = agent_module._build_copilot_input_guardrails(InputGuardrail, GuardrailFunctionOutput)
|
|
output_guardrails = agent_module._build_copilot_output_guardrails(OutputGuardrail, GuardrailFunctionOutput)
|
|
|
|
assert len(input_guardrails) == 1
|
|
assert input_guardrails[0].name == "request_policy_guardrail"
|
|
assert input_guardrails[0].run_in_parallel is False
|
|
assert len(output_guardrails) == 1
|
|
assert output_guardrails[0].name == "copilot_output_policy_guardrail"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sdk_request_policy_guardrail_trips_on_required_clarification() -> None:
|
|
from agents import GuardrailFunctionOutput, InputGuardrail
|
|
from agents.run_context import RunContextWrapper
|
|
|
|
input_guardrails = agent_module._build_copilot_input_guardrails(InputGuardrail, GuardrailFunctionOutput)
|
|
result = await input_guardrails[0].run(
|
|
SimpleNamespace(),
|
|
"input",
|
|
RunContextWrapper(
|
|
context=_ctx(request_policy=_policy(user_response_policy="ask_clarification", allow_update_workflow=False))
|
|
),
|
|
)
|
|
|
|
assert result.output.tripwire_triggered is True
|
|
assert result.output.output_info["user_response_policy"] == "ask_clarification"
|
|
|
|
|
|
def test_sdk_output_guardrail_hard_blocks_raw_secret_final_text() -> None:
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
_ctx(),
|
|
{"type": "REPLY", "user_response": "I used password: hunter2."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert not verdict.allowed
|
|
assert verdict.reason_codes == [OutputPolicyReason.RAW_SECRET_LEAK]
|
|
assert diagnostics == {
|
|
"raw_output_kind": "informational_answer",
|
|
"final_output_kind": "refusal",
|
|
"raw_reason_codes": ["raw_secret_leak"],
|
|
"hard_block_reason_codes": ["raw_secret_leak"],
|
|
"soft_rewrite_reason_codes": [],
|
|
"raw_would_have_failed": True,
|
|
"contained_failure": True,
|
|
"final_output_policy_allowed": False,
|
|
}
|
|
|
|
|
|
def test_actuation_obligation_steers_zero_action_informational_reply() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=None,
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.STEER
|
|
assert evaluation.reason_code == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
|
|
|
|
def test_actuation_obligation_steers_unknown_browser_only_intent() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_unknown_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=None,
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.STEER
|
|
assert evaluation.reason_code == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
|
|
|
|
def test_actuation_obligation_allows_typed_cannot_act_reason() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=CannotActReason.MISSING_FIELD_VALUE,
|
|
prior_turn_outcome=None,
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.ALLOWED
|
|
assert evaluation.cannot_act_reason == CannotActReason.MISSING_FIELD_VALUE
|
|
|
|
|
|
def test_actuation_obligation_repeats_as_terminal() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=TurnOutcome(
|
|
response_kind=ResponseKind.CLARIFY,
|
|
reason_code=ACTUATION_OBLIGATION_STEER_REASON_CODE,
|
|
actuation_obligation_key=ACTUATION_OBLIGATION_BROWSER_ACTION_KEY,
|
|
),
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.TERMINAL
|
|
assert evaluation.reason_code == ACTUATION_OBLIGATION_UNMET_REASON_CODE
|
|
|
|
|
|
def test_actuation_obligation_prior_unmet_stays_terminal() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=TurnOutcome(
|
|
response_kind=ResponseKind.CLARIFY,
|
|
reason_code=ACTUATION_OBLIGATION_UNMET_REASON_CODE,
|
|
terminal_reason=ACTUATION_OBLIGATION_UNMET_REASON_CODE,
|
|
actuation_obligation_key=ACTUATION_OBLIGATION_BROWSER_ACTION_KEY,
|
|
),
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.TERMINAL
|
|
assert evaluation.reason_code == ACTUATION_OBLIGATION_UNMET_REASON_CODE
|
|
|
|
|
|
def test_actuation_obligation_unrelated_prior_steer_stays_recoverable() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=TurnOutcome(
|
|
response_kind=ResponseKind.CLARIFY,
|
|
reason_code=ACTUATION_OBLIGATION_STEER_REASON_CODE,
|
|
actuation_obligation_key="other",
|
|
),
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.STEER
|
|
assert evaluation.reason_code == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [TurnIntentMode.DOCS_ANSWER, TurnIntentMode.DIAGNOSE])
|
|
def test_actuation_obligation_allows_non_actuation_intents(mode: TurnIntentMode) -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=TurnIntent(mode=mode, authority=TurnIntentAuthority(may_update_workflow=False)),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=None,
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.ALLOWED
|
|
|
|
|
|
def test_sdk_output_guardrail_steers_actuation_required_zero_action_reply() -> None:
|
|
evaluation = evaluate_actuation_obligation(
|
|
turn_intent=_browser_actuation_intent(),
|
|
response_type="REPLY",
|
|
output_kind=CopilotOutputKind.INFORMATIONAL_ANSWER,
|
|
successful_mutating_browser_actions=0,
|
|
cannot_act_reason=None,
|
|
prior_turn_outcome=None,
|
|
)
|
|
|
|
assert evaluation.status == ActuationObligationStatus.STEER
|
|
assert evaluation.reason_code == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
|
|
|
|
def test_sdk_output_guardrail_steers_actuation_required_reply() -> None:
|
|
ctx = _ctx(
|
|
turn_intent=_browser_actuation_intent(),
|
|
tool_activity=[{"tool": "evaluate"}, {"tool": "get_browser_screenshot"}],
|
|
)
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "I can fill those fields for you."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert not verdict.allowed
|
|
assert verdict.reason_codes == [OutputPolicyReason.ACTUATION_OBLIGATION_STEER]
|
|
assert diagnostics["actuation_obligation_reason_code"] == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
assert diagnostics["successful_mutating_browser_actions"] == 0
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sdk_output_guardrail_trips_actuation_steer() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent())
|
|
output_guardrails = agent_module._build_copilot_output_guardrails(OutputGuardrail, GuardrailFunctionOutput)
|
|
result = await output_guardrails[0].run(
|
|
RunContextWrapper(context=ctx),
|
|
SimpleNamespace(),
|
|
{"type": "REPLY", "user_response": "I can fill those fields for you."},
|
|
)
|
|
|
|
assert result.output.tripwire_triggered is True
|
|
assert result.output.output_info["actuation_obligation_reason_code"] == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
|
|
|
|
def test_sdk_output_guardrail_repeated_actuation_steer_terminalizes() -> None:
|
|
ctx = _ctx(
|
|
turn_intent=_browser_actuation_intent(),
|
|
prior_turn_outcome=TurnOutcome(
|
|
response_kind=ResponseKind.CLARIFY,
|
|
reason_code=ACTUATION_OBLIGATION_STEER_REASON_CODE,
|
|
actuation_obligation_key=ACTUATION_OBLIGATION_BROWSER_ACTION_KEY,
|
|
),
|
|
)
|
|
output = {"type": "REPLY", "user_response": "I can fill those fields for you."}
|
|
|
|
verdict, _, diagnostics = agent_module._evaluate_copilot_final_output_policy(ctx, output)
|
|
|
|
assert not verdict.allowed
|
|
assert verdict.reason_codes == [OutputPolicyReason.ACTUATION_OBLIGATION_UNMET]
|
|
assert diagnostics["actuation_obligation_status"] == ActuationObligationStatus.TERMINAL.value
|
|
assert diagnostics["actuation_obligation_reason_code"] == ACTUATION_OBLIGATION_UNMET_REASON_CODE
|
|
|
|
|
|
def test_sdk_output_guardrail_rejects_uncorroborated_model_typed_cannot_act_reason() -> None:
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
_ctx(turn_intent=_browser_actuation_intent()),
|
|
{
|
|
"type": "REPLY",
|
|
"user_response": "I need the field value before I can continue.",
|
|
"cannot_act_reason": "missing_field_value",
|
|
},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert not verdict.allowed
|
|
assert diagnostics["cannot_act_reason"] is None
|
|
assert diagnostics["actuation_obligation_reason_code"] == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
|
|
|
|
def test_sdk_output_guardrail_allows_structural_blocker_reason() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent())
|
|
ctx.blocker_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="missing_required_context",
|
|
agent_steering_text="Ask which field value to use.",
|
|
user_facing_reason="I need the field value before I can continue.",
|
|
recovery_hint="ask_user_clarifying",
|
|
)
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "I need the field value before I can continue."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics["cannot_act_reason"] == "missing_field_value"
|
|
|
|
|
|
def test_sdk_output_guardrail_allows_explicit_structural_blocker_reason_code() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent())
|
|
ctx.blocker_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text="Report the unavailable output source.",
|
|
user_facing_reason="The values are not observable on this page.",
|
|
recovery_hint="report_blocker_to_user",
|
|
internal_reason_code=OUTPUT_SOURCE_UNOBSERVABLE_REASON_CODE,
|
|
)
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "The values are not observable on this page."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics["cannot_act_reason"] == "structural_blocker"
|
|
|
|
|
|
def test_sdk_output_guardrail_rejects_generic_report_blocker_reason() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent())
|
|
ctx.blocker_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text="Report the blocker.",
|
|
user_facing_reason="The page is not ready.",
|
|
recovery_hint="report_blocker_to_user",
|
|
internal_reason_code="tool_error_late_block_running",
|
|
)
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "The page is not ready."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert not verdict.allowed
|
|
assert verdict.reason_codes == [OutputPolicyReason.ACTUATION_OBLIGATION_STEER]
|
|
assert diagnostics["cannot_act_reason"] is None
|
|
|
|
|
|
def test_sdk_output_guardrail_allows_successful_mutating_browser_action() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent())
|
|
ctx.scout_trajectory.append({"tool_name": "click"})
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "Clicked the field."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics["successful_mutating_browser_actions"] == 1
|
|
|
|
|
|
def test_sdk_output_guardrail_rejects_form_fill_click_only_reply() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent(), request_policy=_live_fill_policy())
|
|
ctx.scout_trajectory.append({"tool_name": "click"})
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "The requested form interaction was completed."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert not verdict.allowed
|
|
assert verdict.reason_codes == [OutputPolicyReason.ACTUATION_OBLIGATION_STEER]
|
|
assert diagnostics["actuation_obligation_reason_code"] == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
assert diagnostics["successful_mutating_browser_actions"] == 1
|
|
assert diagnostics["successful_durable_browser_fills"] == 0
|
|
assert diagnostics["durable_fill_required"] is True
|
|
|
|
|
|
def test_sdk_output_guardrail_requires_browser_state_for_form_fill_obligation() -> None:
|
|
ctx = _ctx(
|
|
turn_intent=_browser_actuation_intent_without_browser_state(mode=TurnIntentMode.BUILD),
|
|
request_policy=_live_fill_policy(),
|
|
)
|
|
ctx.scout_trajectory.append({"tool_name": "click"})
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "The requested form interaction was completed."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|
|
assert "durable_fill_required" not in diagnostics
|
|
|
|
|
|
def test_sdk_output_guardrail_allows_non_form_click_reply() -> None:
|
|
ctx = _ctx(
|
|
turn_intent=_browser_actuation_intent_without_browser_state(mode=TurnIntentMode.UNKNOWN),
|
|
)
|
|
ctx.scout_trajectory.append({"tool_name": "click"})
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "Clicked the requested target."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|
|
assert "durable_fill_required" not in diagnostics
|
|
|
|
|
|
def test_sdk_output_guardrail_allows_live_fill_after_durable_fill() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent(), request_policy=_live_fill_policy())
|
|
ctx.scout_trajectory.append({"tool_name": "type_text", "typed_length": 3})
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "The requested form interaction was completed."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|
|
assert diagnostics["actuation_obligation_status"] == ActuationObligationStatus.ALLOWED.value
|
|
assert diagnostics["successful_durable_browser_fills"] == 1
|
|
|
|
|
|
def test_sdk_output_guardrail_rejects_live_fill_after_noop_type_text() -> None:
|
|
ctx = _ctx(turn_intent=_browser_actuation_intent(), request_policy=_live_fill_policy())
|
|
ctx.scout_trajectory.append({"tool_name": "type_text", "typed_length": 0, "typed_value": ""})
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "The requested form interaction was completed."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert not verdict.allowed
|
|
assert diagnostics["actuation_obligation_reason_code"] == ACTUATION_OBLIGATION_STEER_REASON_CODE
|
|
assert diagnostics["successful_durable_browser_fills"] == 0
|
|
assert diagnostics["durable_fill_required"] is True
|
|
|
|
|
|
def test_sdk_output_guardrail_allows_unknown_click_with_authority_denied_blocker() -> None:
|
|
ctx = _ctx(turn_intent=_unknown_browser_actuation_intent())
|
|
ctx.scout_trajectory.append({"tool_name": "click"})
|
|
ctx.blocker_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="authority_denied",
|
|
agent_steering_text="Use browser tools.",
|
|
user_facing_reason="I'll respond with the information I already have.",
|
|
recovery_hint="report_blocker_to_user",
|
|
internal_reason_code="turn_intent_no_mutation_run_blocked",
|
|
blocked_tool="update_and_run_blocks",
|
|
classifier_mode="unknown",
|
|
)
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{"type": "REPLY", "user_response": "I'll respond with the information I already have."},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|
|
assert diagnostics["actuation_obligation_status"] == ActuationObligationStatus.ALLOWED.value
|
|
assert diagnostics["cannot_act_reason"] is None
|
|
assert diagnostics["successful_mutating_browser_actions"] == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reason", "expected_terms"),
|
|
[
|
|
(OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE, ("credential", "confirm")),
|
|
(OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED, ("credential", "url", "re-select")),
|
|
],
|
|
)
|
|
def test_output_policy_credential_block_asks_for_credential_confirmation(
|
|
reason: OutputPolicyReason,
|
|
expected_terms: tuple[str, ...],
|
|
) -> None:
|
|
result = agent_module._build_output_policy_blocked_result(
|
|
_ctx(),
|
|
OutputPolicyVerdict(
|
|
reason_codes=[reason],
|
|
),
|
|
prior_global_llm_context="{}",
|
|
prior_workflow_yaml="title: Prior",
|
|
)
|
|
|
|
assert result.response_type == "ASK_QUESTION"
|
|
assert result.clear_proposed_workflow is False
|
|
response = result.user_response.lower()
|
|
for term in expected_terms:
|
|
assert term in response
|
|
assert "I could not safely return" not in result.user_response
|
|
|
|
|
|
def test_output_policy_block_preserves_already_gated_workflow_proposal() -> None:
|
|
ctx = _ctx()
|
|
ctx.last_workflow = SimpleNamespace(name="draft")
|
|
ctx.last_workflow_yaml = "title: Draft"
|
|
ctx.workflow_persisted = True
|
|
ctx.last_test_ok = True
|
|
|
|
result = agent_module._build_output_policy_blocked_result(
|
|
ctx,
|
|
OutputPolicyVerdict(
|
|
reason_codes=[OutputPolicyReason.INTERNAL_TOOL_INSTRUCTION_LEAK],
|
|
),
|
|
prior_global_llm_context="{}",
|
|
prior_workflow_yaml="title: Prior",
|
|
)
|
|
|
|
assert result.response_type == "ASK_QUESTION"
|
|
assert result.updated_workflow is ctx.last_workflow
|
|
assert result.workflow_yaml == "title: Draft"
|
|
assert result.workflow_was_persisted is True
|
|
assert result.clear_proposed_workflow is False
|
|
assert result.proposal_disposition == "review_tested"
|
|
response = result.user_response.lower()
|
|
assert "chat reply" in response
|
|
assert "workflow draft" in response
|
|
assert "saved" in response
|
|
|
|
|
|
def test_output_policy_specific_refusal_preserves_saved_draft_copy() -> None:
|
|
ctx = _ctx()
|
|
ctx.last_workflow = SimpleNamespace(name="draft")
|
|
ctx.last_workflow_yaml = "title: Draft"
|
|
|
|
result = agent_module._build_output_policy_blocked_result(
|
|
ctx,
|
|
OutputPolicyVerdict(
|
|
reason_codes=[OutputPolicyReason.RAW_SECRET_LEAK],
|
|
),
|
|
prior_global_llm_context="{}",
|
|
prior_workflow_yaml="title: Prior",
|
|
)
|
|
|
|
assert result.updated_workflow is ctx.last_workflow
|
|
assert result.clear_proposed_workflow is False
|
|
assert result.proposal_disposition == "review_untested"
|
|
assert "raw credentials or secrets" in result.user_response
|
|
assert "chat reply" in result.user_response
|
|
assert "workflow draft is still saved" in result.user_response
|
|
|
|
|
|
def test_scheduling_credential_policy_block_does_not_use_safety_refusal() -> None:
|
|
scheduling_yaml = """
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: planning_credentials
|
|
default_value: cred_unapproved
|
|
blocks:
|
|
- block_type: navigation
|
|
label: export_current_month_time_clock_csv
|
|
url: https://scheduler.example.test/zeitstempel
|
|
navigation_goal: Waehle den aktuellen Monat aus und exportiere die Zeitstempel CSV fuer Team A und Team B.
|
|
parameter_keys:
|
|
- planning_credentials
|
|
- block_type: navigation
|
|
label: compare_on_call_matrix
|
|
url: https://scheduler.example.test/matrix
|
|
navigation_goal: Vergleiche Matrix und Rufbereitschaft mit der CSV und melde Zeitueberschneidungen.
|
|
parameter_keys:
|
|
- planning_credentials
|
|
"""
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="ASK_QUESTION",
|
|
user_response="Welche URL des Planungs-Tools soll ich verwenden?",
|
|
workflow_yaml=scheduling_yaml,
|
|
has_workflow_proposal=True,
|
|
)
|
|
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
result = agent_module._build_output_policy_blocked_result(
|
|
_ctx(),
|
|
hard_block_output_policy_verdict(verdict),
|
|
prior_global_llm_context="{}",
|
|
prior_workflow_yaml="title: Prior",
|
|
)
|
|
|
|
assert result.response_type == "ASK_QUESTION"
|
|
assert "credential" in result.user_response.lower()
|
|
assert "confirm" in result.user_response.lower()
|
|
assert "I could not safely return" not in result.user_response
|
|
|
|
|
|
def test_sdk_output_guardrail_ignores_internal_context_credential_ids() -> None:
|
|
ctx = _ctx(
|
|
request_policy=_policy(
|
|
credential_input_kind="credential_name",
|
|
credential_refs=["azure_credentials"],
|
|
allow_run_blocks=False,
|
|
allow_missing_credentials_in_draft=True,
|
|
),
|
|
)
|
|
ctx.last_workflow = object()
|
|
ctx.last_workflow_yaml = """
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: azure_credentials
|
|
blocks:
|
|
- block_type: login
|
|
label: login
|
|
url: https://example.com/login
|
|
parameter_keys:
|
|
- azure_credentials
|
|
"""
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{
|
|
"type": "REPLY",
|
|
"user_response": "I have a draft workflow proposal. It has not been tested.",
|
|
"global_llm_context": "Tool observation listed cred_unrelated for a different saved credential.",
|
|
},
|
|
)
|
|
|
|
assert response_type == "REPLY"
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE not in verdict.reason_codes
|
|
assert diagnostics["raw_would_have_failed"] is False
|
|
assert diagnostics["contained_failure"] is False
|
|
|
|
|
|
def test_sdk_output_guardrail_defers_raw_question_after_untested_draft() -> None:
|
|
ctx = _ctx(
|
|
request_policy=_policy(
|
|
testing_intent="skip_test",
|
|
credential_input_kind="credential_name",
|
|
credential_refs=["azure_credentials"],
|
|
allow_run_blocks=False,
|
|
allow_missing_credentials_in_draft=True,
|
|
resolved_credentials=[],
|
|
),
|
|
)
|
|
ctx.allow_untested_workflow_draft = True
|
|
ctx.last_workflow = object()
|
|
ctx.last_update_block_count = 9
|
|
ctx.last_workflow_yaml = """
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: azure_credentials
|
|
blocks:
|
|
- block_type: login
|
|
label: login
|
|
url: https://example.com/login
|
|
parameter_keys:
|
|
- azure_credentials
|
|
"""
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
ctx,
|
|
{
|
|
"type": "ASK_QUESTION",
|
|
"user_response": "I found cred_unrelated in an internal tool observation. Should I use it?",
|
|
},
|
|
)
|
|
|
|
assert response_type == "ASK_QUESTION"
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE not in verdict.reason_codes
|
|
assert diagnostics["raw_output_kind"] == "workflow_draft_proposal"
|
|
assert diagnostics["final_output_kind"] == "workflow_draft_proposal"
|
|
|
|
|
|
def test_translation_surfaces_untested_draft_when_agent_asks_after_drafting() -> None:
|
|
ctx = _ctx(
|
|
request_policy=_policy(
|
|
testing_intent="skip_test",
|
|
credential_input_kind="credential_name",
|
|
credential_refs=["azure_credentials"],
|
|
allow_run_blocks=False,
|
|
allow_missing_credentials_in_draft=True,
|
|
resolved_credentials=[],
|
|
),
|
|
)
|
|
ctx.allow_untested_workflow_draft = True
|
|
ctx.last_workflow = object()
|
|
ctx.last_update_block_count = 9
|
|
ctx.workflow_persisted = True
|
|
ctx.last_workflow_yaml = """
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: azure_credentials
|
|
blocks:
|
|
- block_type: login
|
|
label: login
|
|
url: https://example.com/login
|
|
parameter_keys:
|
|
- azure_credentials
|
|
"""
|
|
|
|
result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
_fake_run_result(
|
|
{
|
|
"type": "ASK_QUESTION",
|
|
"user_response": "I found cred_unrelated in an internal tool observation. Should I use it?",
|
|
"global_llm_context": "{}",
|
|
}
|
|
),
|
|
ctx,
|
|
"{}",
|
|
_chat_request(),
|
|
"org-1",
|
|
)
|
|
)
|
|
|
|
assert result.response_type == "REPLY"
|
|
assert result.updated_workflow is ctx.last_workflow
|
|
assert result.proposal_disposition == "review_untested"
|
|
assert result.clear_proposed_workflow is False
|
|
assert "without testing it" in result.user_response
|
|
assert "not been verified" in result.user_response
|
|
assert "cred_unrelated" not in result.user_response
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_mutation_tools_have_sdk_input_guardrails_and_reject_raw_secret() -> None:
|
|
guarded_tools = {
|
|
tool.name: tool for tool in NATIVE_TOOLS if tool.name in {"update_workflow", "update_and_run_blocks"}
|
|
}
|
|
assert guarded_tools.keys() == {"update_workflow", "update_and_run_blocks"}
|
|
assert guarded_tools["update_workflow"].tool_input_guardrails == [_WORKFLOW_YAML_OUTPUT_POLICY_GUARDRAIL]
|
|
assert guarded_tools["update_and_run_blocks"].tool_input_guardrails == [_WORKFLOW_YAML_OUTPUT_POLICY_GUARDRAIL]
|
|
|
|
ctx = _ctx()
|
|
ctx.consecutive_tool_tracker = ["update_workflow", "update_workflow"]
|
|
ctx.failed_tool_step_tracker = {"sentinel": 2}
|
|
|
|
result = await _WORKFLOW_YAML_OUTPUT_POLICY_GUARDRAIL.run(
|
|
ToolInputGuardrailData(
|
|
context=ToolContext(
|
|
context=ctx,
|
|
tool_name="update_and_run_blocks",
|
|
tool_call_id="call-1",
|
|
tool_arguments=json.dumps(
|
|
{
|
|
"workflow_yaml": """
|
|
workflow_definition:
|
|
blocks:
|
|
- block_type: navigation
|
|
label: login
|
|
navigation_goal: Type password: hunter2 into the password field.
|
|
"""
|
|
}
|
|
),
|
|
),
|
|
agent=SimpleNamespace(),
|
|
)
|
|
)
|
|
|
|
assert result.behavior["type"] == "reject_content"
|
|
assert "raw_secret_leak" in result.behavior["message"]
|
|
assert ctx.consecutive_tool_tracker == ["update_and_run_blocks"]
|
|
assert ctx.failed_tool_step_tracker == {"sentinel": 2}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowed_sdk_input_guardrail_does_not_record_consecutive_boundary() -> None:
|
|
ctx = _ctx()
|
|
ctx.consecutive_tool_tracker = ["update_workflow", "update_workflow"]
|
|
|
|
result = await _WORKFLOW_YAML_OUTPUT_POLICY_GUARDRAIL.run(
|
|
ToolInputGuardrailData(
|
|
context=ToolContext(
|
|
context=ctx,
|
|
tool_name="update_and_run_blocks",
|
|
tool_call_id="call-allow",
|
|
tool_arguments=json.dumps(
|
|
{
|
|
"workflow_yaml": """
|
|
workflow_definition:
|
|
blocks:
|
|
- block_type: goto_url
|
|
label: open_example
|
|
url: https://example.com
|
|
"""
|
|
}
|
|
),
|
|
),
|
|
agent=SimpleNamespace(),
|
|
)
|
|
)
|
|
|
|
assert result.behavior["type"] == "allow"
|
|
assert ctx.consecutive_tool_tracker == ["update_workflow", "update_workflow"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_and_run_blocks_rejects_unobserved_page_before_workflow_update(monkeypatch) -> None:
|
|
from skyvern.forge.sdk.copilot import tools as tools_module
|
|
|
|
workflow_yaml = """
|
|
workflow_definition:
|
|
parameters: []
|
|
blocks:
|
|
- block_type: goto_url
|
|
label: open_lookup
|
|
url: https://example.com/lookup
|
|
- block_type: navigation
|
|
label: search_lookup
|
|
navigation_goal: Enter the observed field and submit.
|
|
"""
|
|
ctx = _ctx(
|
|
build_phase=BuildPhase.COMPOSING,
|
|
turn_intent=TurnIntent(mode=TurnIntentMode.BUILD),
|
|
)
|
|
|
|
async def unexpected_prior_definition(*args: object, **kwargs: object) -> object:
|
|
raise AssertionError("composition evidence precheck must run before reading prior workflow")
|
|
|
|
async def unexpected_update_workflow(*args: object, **kwargs: object) -> object:
|
|
raise AssertionError("composition evidence precheck must run before updating workflow")
|
|
|
|
sanitized_tool_names: list[str] = []
|
|
|
|
def fake_sanitize_tool_result_for_llm(tool_name: str, result: dict[str, object]) -> dict[str, object]:
|
|
sanitized_tool_names.append(tool_name)
|
|
return result
|
|
|
|
monkeypatch.setattr(tools_module, "_request_policy_allows_update_and_skip_run", lambda *args: False)
|
|
monkeypatch.setattr(tools_module, "_authority_tool_error", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(tools_module, "_tool_loop_error", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(tools_module, "_get_prior_workflow_definition", unexpected_prior_definition)
|
|
monkeypatch.setattr(tools_module, "_update_workflow", unexpected_update_workflow)
|
|
monkeypatch.setattr(tools_module, "sanitize_tool_result_for_llm", fake_sanitize_tool_result_for_llm)
|
|
|
|
result = await tools_module.update_and_run_blocks_tool.on_invoke_tool(
|
|
SimpleNamespace(context=ctx, tool_name="update_and_run_blocks"),
|
|
json.dumps(
|
|
{
|
|
"workflow_yaml": workflow_yaml,
|
|
"block_labels": ["search_lookup"],
|
|
"parameters": {},
|
|
}
|
|
),
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["ok"] is False
|
|
assert "inspect_page_for_composition" in payload["error"]
|
|
assert "https://example.com/lookup" in payload["error"]
|
|
assert ctx.block_observation_refs == {}
|
|
assert sanitized_tool_names == ["update_and_run_blocks"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_workflow_runs_composition_evidence_precheck_before_saving(monkeypatch) -> None:
|
|
from skyvern.forge.sdk.copilot import tools as tools_module
|
|
|
|
workflow_yaml = """
|
|
workflow_definition:
|
|
parameters: []
|
|
blocks:
|
|
- block_type: goto_url
|
|
label: open_lookup
|
|
url: https://example.com/lookup
|
|
- block_type: navigation
|
|
label: search_lookup
|
|
navigation_goal: Enter the observed field and submit.
|
|
- block_type: action
|
|
label: expand_first_result
|
|
navigation_goal: Expand the first result row.
|
|
"""
|
|
ctx = _ctx(
|
|
build_phase=BuildPhase.COMPOSING,
|
|
turn_intent=TurnIntent(mode=TurnIntentMode.BUILD),
|
|
request_policy=RequestPolicy(),
|
|
)
|
|
|
|
async def unexpected_update_workflow(*args: object, **kwargs: object) -> object:
|
|
raise AssertionError("composition evidence precheck must run before update_workflow saves")
|
|
|
|
monkeypatch.setattr(tools_module, "_tool_loop_error", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(tools_module, "_update_workflow", unexpected_update_workflow)
|
|
monkeypatch.setattr(tools_module, "_record_diagnosis_repair_contract", lambda *args, **kwargs: None)
|
|
|
|
result = await tools_module.update_workflow_tool.on_invoke_tool(
|
|
SimpleNamespace(context=ctx, tool_name="update_workflow"),
|
|
json.dumps({"workflow_yaml": workflow_yaml}),
|
|
)
|
|
|
|
payload = json.loads(result)
|
|
assert payload["ok"] is False
|
|
assert "page-dependent build blocks need observed page evidence" in payload["error"]
|
|
assert "https://example.com/lookup" in payload["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_and_run_blocks_precheck_uses_proposed_block_observation_refs(monkeypatch) -> None:
|
|
from skyvern.forge.sdk.copilot import tools as tools_module
|
|
|
|
workflow_yaml = """
|
|
workflow_definition:
|
|
parameters: []
|
|
blocks:
|
|
- block_type: goto_url
|
|
label: open_home
|
|
url: https://example.com/
|
|
- block_type: action
|
|
label: open_results
|
|
navigation_goal: Open the observed result list.
|
|
- block_type: extraction
|
|
label: read_results
|
|
data_extraction_goal: Read the visible result rows.
|
|
"""
|
|
ctx = _ctx(
|
|
build_phase=BuildPhase.COMPOSING,
|
|
turn_intent=TurnIntent(mode=TurnIntentMode.BUILD),
|
|
flow_evidence=[
|
|
{
|
|
"step": 0,
|
|
"reached_via": "navigate",
|
|
"url": "https://example.com/",
|
|
"had_bounded_schema": True,
|
|
"evidence": {
|
|
"source_tool": "inspect_page_for_composition",
|
|
"inspected_url": "https://example.com/",
|
|
"current_url": "https://example.com/",
|
|
"forms": [{"fields": [{"name": "q", "selector": "#q"}], "submit_controls": []}],
|
|
"navigation_targets": [],
|
|
"result_containers": [],
|
|
"challenge_controls": [],
|
|
},
|
|
},
|
|
{
|
|
"step": 1,
|
|
"reached_via": "interaction",
|
|
"url": "https://example.com/results",
|
|
"had_bounded_schema": True,
|
|
"evidence": {
|
|
"source_tool": "inspect_page_for_composition",
|
|
"inspected_url": "https://example.com/results",
|
|
"current_url": "https://example.com/results",
|
|
"forms": [],
|
|
"navigation_targets": [],
|
|
"result_containers": [{"selector": "#results"}],
|
|
"challenge_controls": [],
|
|
},
|
|
},
|
|
],
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_update_workflow(payload: dict, update_ctx: CopilotContext, **kwargs: object) -> dict:
|
|
captured["payload"] = payload
|
|
workflow = SimpleNamespace(workflow_definition={"blocks": [{"label": "open_results"}]})
|
|
update_ctx.last_workflow = workflow
|
|
update_ctx.last_update_block_count = 2
|
|
return {"ok": True, "_workflow": workflow, "data": {"block_count": 2}}
|
|
|
|
async def fake_run_blocks(params: dict, run_ctx: CopilotContext, **kwargs: object) -> dict:
|
|
return {
|
|
"ok": True,
|
|
"data": {
|
|
"workflow_run_id": "wr-1",
|
|
"overall_status": "completed",
|
|
"blocks": [],
|
|
},
|
|
}
|
|
|
|
async def fake_prior_definition(update_ctx: CopilotContext) -> object:
|
|
return None
|
|
|
|
monkeypatch.setattr(tools_module, "_request_policy_allows_update_and_skip_run", lambda *args: False)
|
|
monkeypatch.setattr(tools_module, "_authority_tool_error", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(tools_module, "_tool_loop_error", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(tools_module, "_get_prior_workflow_definition", fake_prior_definition)
|
|
monkeypatch.setattr(tools_module, "_update_workflow", fake_update_workflow)
|
|
monkeypatch.setattr(tools_module, "_plan_frontier", lambda *args: (["open_results"], {}, "open_results"))
|
|
monkeypatch.setattr(tools_module, "_frontier_run_size_error", lambda *args: None)
|
|
monkeypatch.setattr(tools_module, "_run_blocks_and_collect_debug", fake_run_blocks)
|
|
monkeypatch.setattr(tools_module, "_record_diagnosis_repair_contract", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(tools_module, "enqueue_screenshot_from_result", lambda *args, **kwargs: None)
|
|
|
|
result = await tools_module.update_and_run_blocks_tool.on_invoke_tool(
|
|
SimpleNamespace(context=ctx, tool_name="update_and_run_blocks"),
|
|
json.dumps(
|
|
{
|
|
"workflow_yaml": workflow_yaml,
|
|
"block_labels": ["open_results", "read_results"],
|
|
"block_observation_refs": [
|
|
{"label": "open_results", "observation_step": 0},
|
|
{"label": "read_results", "observation_step": 1},
|
|
],
|
|
"code_artifact_metadata": _code_artifact_metadata(),
|
|
"parameters": {},
|
|
}
|
|
),
|
|
)
|
|
|
|
assert json.loads(result)["ok"] is True
|
|
assert captured["payload"]["workflow_yaml"] == workflow_yaml
|
|
assert captured["payload"]["block_observation_refs"] == {
|
|
"open_results": 0,
|
|
"read_results": 1,
|
|
}
|
|
assert [ref.model_dump() for ref in captured["payload"]["raw_block_observation_refs"]] == [
|
|
{"label": "open_results", "observation_step": 0},
|
|
{"label": "read_results", "observation_step": 1},
|
|
]
|
|
assert captured["payload"]["code_artifact_metadata"][0]["block_label"] == "open_results"
|
|
assert captured["payload"]["code_artifact_metadata"][0]["claimed_outcomes"][0]["id"] == "claim:open_results"
|
|
assert (
|
|
captured["payload"]["code_artifact_metadata"][0]["evidence_refs"][0]["evidence_ref"] == "evidence:result_link"
|
|
)
|
|
assert [
|
|
item.model_dump(mode="json", exclude_none=True) for item in captured["payload"]["raw_code_artifact_metadata"]
|
|
] == captured["payload"]["code_artifact_metadata"]
|
|
assert ctx.block_observation_refs == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_workflow_rejects_raw_secret_before_processing(monkeypatch) -> None:
|
|
from skyvern.forge.sdk.copilot.tools import _update_workflow
|
|
|
|
process_mock = MagicMock()
|
|
monkeypatch.setattr("skyvern.forge.sdk.copilot.tools.workflow_update._process_workflow_yaml", process_mock)
|
|
|
|
result = await _update_workflow(
|
|
{
|
|
"workflow_yaml": """
|
|
workflow_definition:
|
|
blocks:
|
|
- block_type: navigation
|
|
label: login
|
|
navigation_goal: Type password: hunter2 into the password field.
|
|
"""
|
|
},
|
|
_ctx(),
|
|
allow_missing_credentials=True,
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert "raw_secret_leak" in result["error"]
|
|
assert "<key>.password" in result["error"]
|
|
assert "do not split, concatenate, or obfuscate" in result["error"]
|
|
process_mock.assert_not_called()
|
|
|
|
|
|
def test_inline_replace_workflow_rejects_raw_secret_before_processing(monkeypatch) -> None:
|
|
process_mock = MagicMock()
|
|
monkeypatch.setattr("skyvern.forge.sdk.copilot.tools.workflow_update._process_workflow_yaml", process_mock)
|
|
result = _fake_run_result(
|
|
{
|
|
"type": "REPLACE_WORKFLOW",
|
|
"user_response": "Here is the workflow.",
|
|
"workflow_yaml": """
|
|
workflow_definition:
|
|
blocks:
|
|
- block_type: navigation
|
|
label: login
|
|
navigation_goal: Type password: hunter2 into the password field.
|
|
""",
|
|
}
|
|
)
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert agent_result.response_type == "ASK_QUESTION"
|
|
assert agent_result.updated_workflow is None
|
|
assert agent_result.clear_proposed_workflow is False
|
|
assert agent_result.proposal_disposition == "no_proposal"
|
|
process_mock.assert_not_called()
|
|
|
|
|
|
def test_translate_to_agent_result_blocks_raw_secret_final_text() -> None:
|
|
result = _fake_run_result({"type": "REPLY", "user_response": "I used password: hunter2."})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert agent_result.response_type == "ASK_QUESTION"
|
|
assert agent_result.updated_workflow is None
|
|
assert agent_result.clear_proposed_workflow is False
|
|
assert agent_result.proposal_disposition == "no_proposal"
|
|
assert "hunter2" not in agent_result.user_response
|
|
assert "DO NOT PROVIDE RAW LOGIN/PASSWORD" in agent_result.user_response
|
|
|
|
|
|
def test_translate_scrubs_late_block_running_leak_and_preserves_draft() -> None:
|
|
ctx = _ctx()
|
|
saved_workflow = object()
|
|
ctx.last_workflow = saved_workflow
|
|
ctx.last_workflow_yaml = "workflow_definition:\n blocks: []\n"
|
|
ctx.blocker_signal = CopilotToolBlockerSignal(
|
|
blocker_kind="tool_error",
|
|
agent_steering_text="Stop tool use and answer from gathered evidence.",
|
|
user_facing_reason="I'm running out of time on this turn. I'll wrap up with what I have so far.",
|
|
recovery_hint="stop",
|
|
cleared_by_tools=frozenset(),
|
|
preserves_workflow_draft=True,
|
|
renders_final_reply=False,
|
|
internal_reason_code="tool_error_late_block_running",
|
|
blocked_tool="update_and_run_blocks",
|
|
)
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
_fake_run_result(
|
|
{
|
|
"type": "REPLY",
|
|
"user_response": (
|
|
"Less than 90 seconds remain in this Copilot turn after the previous workflow run failed. "
|
|
"Do NOT retry block-running tools."
|
|
),
|
|
}
|
|
),
|
|
ctx,
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert agent_result.updated_workflow is saved_workflow
|
|
assert agent_result.clear_proposed_workflow is False
|
|
assert agent_result.proposal_disposition == "review_untested"
|
|
assert "Do NOT retry" not in agent_result.user_response
|
|
assert "workflow draft is still saved" in agent_result.user_response
|
|
|
|
|
|
def test_timeout_exit_scrubs_recorded_late_block_running_leak_and_preserves_draft() -> None:
|
|
ctx = _ctx()
|
|
saved_workflow = object()
|
|
ctx.last_workflow = saved_workflow
|
|
ctx.last_workflow_yaml = "workflow_definition:\n blocks: []\n"
|
|
ctx.last_update_block_count = 5
|
|
ctx.last_test_ok = False
|
|
ctx.last_test_failure_reason = (
|
|
"Less than 90 seconds remain in this Copilot turn after the previous workflow run failed. "
|
|
"Do NOT retry block-running tools."
|
|
)
|
|
|
|
agent_result = agent_module._build_timeout_exit_result(ctx, global_llm_context=None)
|
|
|
|
assert agent_result.updated_workflow is saved_workflow
|
|
assert agent_result.clear_proposed_workflow is False
|
|
assert agent_result.proposal_disposition == "review_untested"
|
|
assert "Do NOT retry" not in agent_result.user_response
|
|
assert "draft workflow proposal" in agent_result.user_response
|
|
|
|
|
|
def test_translate_to_agent_result_rewrites_unbacked_workflow_claim() -> None:
|
|
result = _fake_run_result({"type": "REPLY", "user_response": "I've drafted a workflow for you."})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "wasn't able to produce a workflow proposal" in agent_result.user_response
|
|
assert "provide the missing details" not in agent_result.user_response
|
|
assert "couldn't identify which details were missing" in agent_result.user_response
|
|
assert agent_result.response_type == "ASK_QUESTION"
|
|
assert agent_result.updated_workflow is None
|
|
assert agent_result.output_policy_diagnostics == {
|
|
"raw_output_kind": "informational_answer",
|
|
"final_output_kind": "clarification_request",
|
|
"raw_reason_codes": ["unbacked_workflow_delivery_claim", "missing_proposal_state"],
|
|
"hard_block_reason_codes": [],
|
|
"soft_rewrite_reason_codes": ["unbacked_workflow_delivery_claim", "missing_proposal_state"],
|
|
"raw_would_have_failed": True,
|
|
"contained_failure": True,
|
|
"final_output_policy_allowed": True,
|
|
}
|
|
|
|
|
|
def test_translate_to_agent_result_rewrites_deprecated_block_taxonomy() -> None:
|
|
result = _fake_run_result(
|
|
{
|
|
"type": "REPLY",
|
|
"user_response": (
|
|
"`task_v2` is deprecated. Use these newer block types instead: "
|
|
"`navigation`, `extraction`, `validation`, `login`, `goto_url`, and `file_download`."
|
|
),
|
|
}
|
|
)
|
|
|
|
with patch("skyvern.forge.sdk.copilot.agent.LOG.info") as log_info:
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "task_v2" not in agent_result.user_response
|
|
assert "`navigation`" not in agent_result.user_response
|
|
assert "`extraction`" not in agent_result.user_response
|
|
assert "Describe the page action" in agent_result.user_response
|
|
assert agent_result.response_type == "REPLY"
|
|
assert agent_result.updated_workflow is None
|
|
final_log = next(call for call in log_info.call_args_list if call.args[0] == "copilot output policy final verdict")
|
|
assert final_log.kwargs["allowed"] is True
|
|
assert final_log.kwargs["reason_codes"] == []
|
|
assert final_log.kwargs["raw_output_kind"] == "informational_answer"
|
|
assert final_log.kwargs["final_output_kind"] == "informational_answer"
|
|
assert final_log.kwargs["hard_block_reason_codes"] == []
|
|
assert final_log.kwargs["soft_rewrite_reason_codes"] == ["internal_block_taxonomy_leak"]
|
|
assert final_log.kwargs["raw_would_have_failed"] is True
|
|
assert final_log.kwargs["contained_failure"] is True
|
|
assert agent_result.output_policy_diagnostics == {
|
|
"raw_output_kind": "informational_answer",
|
|
"final_output_kind": "informational_answer",
|
|
"raw_reason_codes": ["internal_block_taxonomy_leak"],
|
|
"hard_block_reason_codes": [],
|
|
"soft_rewrite_reason_codes": ["internal_block_taxonomy_leak"],
|
|
"raw_would_have_failed": True,
|
|
"contained_failure": True,
|
|
"final_output_policy_allowed": True,
|
|
}
|
|
|
|
|
|
def test_translate_to_agent_result_rewrites_internal_classifier_vocab_leak() -> None:
|
|
leak = (
|
|
"TurnIntent classified this turn as `edit`, so the request couldn't continue. "
|
|
"safe_reason_code=turn_intent_no_mutation_run_blocked."
|
|
)
|
|
result = _fake_run_result({"type": "REPLY", "user_response": leak})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "TurnIntent" not in agent_result.user_response
|
|
assert "safe_reason_code" not in agent_result.user_response
|
|
assert "Tell me what you'd like to do next" in agent_result.user_response
|
|
diagnostics = agent_result.output_policy_diagnostics or {}
|
|
assert "internal_classifier_vocab_leak" in diagnostics["soft_rewrite_reason_codes"]
|
|
assert "internal_classifier_vocab_leak" in diagnostics["raw_reason_codes"]
|
|
assert diagnostics["raw_would_have_failed"] is True
|
|
assert diagnostics["contained_failure"] is True
|
|
|
|
|
|
def test_translate_to_agent_result_hard_blocks_extended_taxonomy_tool_name_leak() -> None:
|
|
leak = "Trace shows `get_run_results` was the last tool dispatched on this turn."
|
|
result = _fake_run_result({"type": "REPLY", "user_response": leak})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "get_run_results" not in agent_result.user_response
|
|
assert "I could not safely return" in agent_result.user_response
|
|
diagnostics = agent_result.output_policy_diagnostics or {}
|
|
assert "internal_tool_instruction_leak" in diagnostics["hard_block_reason_codes"]
|
|
assert "internal_block_taxonomy_leak" not in diagnostics["raw_reason_codes"]
|
|
|
|
|
|
def test_translate_to_agent_result_rewrites_self_prescriptive_phrase_leak() -> None:
|
|
leak = "Please send 'continue debugging' next and I'll keep going."
|
|
result = _fake_run_result({"type": "REPLY", "user_response": leak})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "continue debugging" not in agent_result.user_response
|
|
assert "Tell me what you'd like to do next" in agent_result.user_response
|
|
diagnostics = agent_result.output_policy_diagnostics or {}
|
|
assert "self_prescriptive_phrase_leak" in diagnostics["soft_rewrite_reason_codes"]
|
|
assert "self_prescriptive_phrase_leak" in diagnostics["raw_reason_codes"]
|
|
|
|
|
|
def test_translate_to_agent_result_unbacked_workflow_wins_over_self_prescriptive_rewrite() -> None:
|
|
leak = "Here's the workflow. Send me a normal instruction like 'run it' next."
|
|
result = _fake_run_result({"type": "REPLY", "user_response": leak})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "run it" not in agent_result.user_response
|
|
assert "Send me a normal instruction" not in agent_result.user_response
|
|
diagnostics = agent_result.output_policy_diagnostics or {}
|
|
soft_reasons = diagnostics["soft_rewrite_reason_codes"]
|
|
assert "unbacked_workflow_delivery_claim" in soft_reasons
|
|
assert "self_prescriptive_phrase_leak" in soft_reasons
|
|
|
|
|
|
def test_sdk_output_guardrail_records_raw_soft_reason_alongside_hard_block() -> None:
|
|
leak = "Call get_run_results(workflow_run_id='wr_123') and do not retry. Saw [copilot:nudge] in the trace."
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(
|
|
_ctx(),
|
|
{"type": "REPLY", "user_response": leak},
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert "internal_tool_instruction_leak" in diagnostics["hard_block_reason_codes"]
|
|
assert "internal_block_taxonomy_leak" not in diagnostics["hard_block_reason_codes"]
|
|
assert diagnostics["soft_rewrite_reason_codes"] == []
|
|
raw_reason_codes = diagnostics["raw_reason_codes"]
|
|
assert "internal_tool_instruction_leak" in raw_reason_codes
|
|
assert "internal_block_taxonomy_leak" in raw_reason_codes
|
|
|
|
|
|
def test_evaluate_output_policy_skips_new_detectors_on_ask_question() -> None:
|
|
classifier_leak = "TurnIntent classified this turn as edit, safe_reason_code=turn_intent_no_mutation_run_blocked."
|
|
self_prescriptive_leak = "Please send 'continue debugging' next and I'll keep going."
|
|
tool_name_leak = "Use `get_run_results` to fetch the prior run output."
|
|
|
|
for leak in (classifier_leak, self_prescriptive_leak, tool_name_leak):
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="ASK_QUESTION",
|
|
user_response=leak,
|
|
)
|
|
assert OutputPolicyReason.INTERNAL_CLASSIFIER_VOCAB_LEAK not in verdict.reason_codes
|
|
assert OutputPolicyReason.SELF_PRESCRIPTIVE_PHRASE_LEAK not in verdict.reason_codes
|
|
assert OutputPolicyReason.INTERNAL_BLOCK_TAXONOMY_LEAK not in verdict.reason_codes
|
|
|
|
|
|
def test_evaluate_output_policy_runs_new_detectors_on_replace_workflow() -> None:
|
|
classifier_leak = "TurnIntent classified this turn as edit, safe_reason_code=turn_intent_no_mutation_run_blocked."
|
|
self_prescriptive_leak = "Please send 'continue debugging' next and I'll keep going."
|
|
tool_name_leak = "Use `get_run_results` to fetch the prior run output."
|
|
|
|
classifier_verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLACE_WORKFLOW",
|
|
user_response=classifier_leak,
|
|
)
|
|
assert OutputPolicyReason.INTERNAL_CLASSIFIER_VOCAB_LEAK in classifier_verdict.reason_codes
|
|
|
|
self_prescriptive_verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLACE_WORKFLOW",
|
|
user_response=self_prescriptive_leak,
|
|
)
|
|
assert OutputPolicyReason.SELF_PRESCRIPTIVE_PHRASE_LEAK in self_prescriptive_verdict.reason_codes
|
|
|
|
tool_name_verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLACE_WORKFLOW",
|
|
user_response=tool_name_leak,
|
|
)
|
|
assert OutputPolicyReason.INTERNAL_TOOL_INSTRUCTION_LEAK in tool_name_verdict.reason_codes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_response,expected",
|
|
[
|
|
("LOOP DETECTED: same tool dispatched three times in a row.", True),
|
|
("Saw [copilot:nudge] in the trace before the failure.", True),
|
|
("Couldn't finish the diagnostic step on this nudge turn.", True),
|
|
("nudge-turn was cleared before the next iteration.", True),
|
|
("Update the workflow to log in before extracting data.", False),
|
|
("I'll get the run results once it finishes.", False),
|
|
("", False),
|
|
],
|
|
)
|
|
def test_contains_internal_tool_vocab_leak_helper(user_response: str, expected: bool) -> None:
|
|
assert _contains_internal_tool_vocab_leak(user_response) is expected
|
|
|
|
|
|
def test_translate_to_agent_result_prioritizes_unbacked_workflow_claim_over_taxonomy_rewrite() -> None:
|
|
result = _fake_run_result(
|
|
{
|
|
"type": "REPLY",
|
|
"user_response": "I've drafted a workflow for you using `task_v2`.",
|
|
}
|
|
)
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "wasn't able to produce a workflow proposal" in agent_result.user_response
|
|
assert "task_v2" not in agent_result.user_response
|
|
assert agent_result.output_policy_diagnostics is not None
|
|
assert "unbacked_workflow_delivery_claim" in agent_result.output_policy_diagnostics["soft_rewrite_reason_codes"]
|
|
assert agent_result.updated_workflow is None
|
|
|
|
|
|
def test_translate_to_agent_result_rewrites_block_yaml_pasted_in_reply() -> None:
|
|
leak = (
|
|
"I've now updated the workflow to also accept the form's URL as a parameter, named `form_url`. "
|
|
"Here's how the block now looks:\n\n"
|
|
" - label: navigate_and_fill_form\n"
|
|
" block_type: navigation\n"
|
|
" navigation_goal: Fill the abuse form.\n"
|
|
' url: "{{ form_url }}"\n'
|
|
" parameter_keys:\n"
|
|
" - name\n"
|
|
" - form_url\n"
|
|
)
|
|
result = _fake_run_result({"type": "REPLY", "user_response": leak})
|
|
|
|
with patch("skyvern.forge.sdk.copilot.agent.LOG.info") as log_info:
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "block_type" not in agent_result.user_response
|
|
assert "navigation_goal" not in agent_result.user_response
|
|
assert "parameter_keys" not in agent_result.user_response
|
|
assert "haven't applied it yet" in agent_result.user_response
|
|
assert agent_result.updated_workflow is None
|
|
final_log = next(call for call in log_info.call_args_list if call.args[0] == "copilot output policy final verdict")
|
|
assert final_log.kwargs["soft_rewrite_reason_codes"] == ["workflow_yaml_in_reply"]
|
|
assert final_log.kwargs["contained_failure"] is True
|
|
assert agent_result.output_policy_diagnostics["soft_rewrite_reason_codes"] == ["workflow_yaml_in_reply"]
|
|
assert agent_result.output_policy_diagnostics["final_output_policy_allowed"] is True
|
|
|
|
|
|
def test_translate_to_agent_result_rewrites_block_yaml_when_workflow_attached() -> None:
|
|
leak = (
|
|
"I've now updated the workflow to also accept the form's URL as a parameter, named `form_url`. "
|
|
"Here's how the block now looks:\n\n"
|
|
" - label: navigate_and_fill_form\n"
|
|
" block_type: navigation\n"
|
|
" navigation_goal: Fill the abuse form.\n"
|
|
" parameter_keys:\n"
|
|
" - form_url\n"
|
|
)
|
|
result = _fake_run_result({"type": "REPLY", "user_response": leak})
|
|
|
|
workflow = SimpleNamespace(name="draft")
|
|
ctx = _ctx()
|
|
ctx.last_workflow = workflow
|
|
ctx.last_workflow_yaml = _workflow_yaml()
|
|
ctx.last_test_ok = True
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
ctx,
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert "block_type" not in agent_result.user_response
|
|
assert "navigation_goal" not in agent_result.user_response
|
|
assert "parameter_keys" not in agent_result.user_response
|
|
assert "haven't applied it yet" not in agent_result.user_response
|
|
assert "made the change" in agent_result.user_response
|
|
assert agent_result.updated_workflow is workflow
|
|
|
|
|
|
def test_translate_to_agent_result_adds_unvalidated_affordance() -> None:
|
|
workflow = SimpleNamespace(name="draft")
|
|
result = _fake_run_result({"type": "REPLY", "user_response": "I drafted the workflow."})
|
|
|
|
agent_result = asyncio.run(
|
|
agent_module._translate_to_agent_result(
|
|
result,
|
|
_ctx(last_workflow=workflow, last_workflow_yaml="title: draft", last_test_ok=None),
|
|
global_llm_context=None,
|
|
chat_request=_chat_request(),
|
|
organization_id="org-1",
|
|
)
|
|
)
|
|
|
|
assert agent_result.updated_workflow is workflow
|
|
assert "Accept to save" in agent_result.user_response
|
|
assert "Reject to discard" in agent_result.user_response
|
|
|
|
|
|
def _two_credential_workflow_yaml() -> str:
|
|
return """
|
|
workflow_definition:
|
|
parameters:
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: site_a_credentials
|
|
default_value: cred_amazon
|
|
- parameter_type: workflow
|
|
workflow_parameter_type: credential_id
|
|
key: site_b_credentials
|
|
default_value: cred_quicken
|
|
blocks:
|
|
- block_type: login
|
|
label: login_a
|
|
url: https://login-a.authenticationtest.test/login
|
|
navigation_goal: Log in to site A.
|
|
parameter_keys:
|
|
- site_a_credentials
|
|
- block_type: login
|
|
label: login_b
|
|
url: https://login-b.authenticationtest.test/login
|
|
navigation_goal: Log in to site B.
|
|
parameter_keys:
|
|
- site_b_credentials
|
|
"""
|
|
|
|
|
|
def _discovered(credential_id: str, tested_url: str | None) -> object:
|
|
return SimpleNamespace(credential_id=credential_id, name=credential_id, tested_url=tested_url)
|
|
|
|
|
|
def test_allows_discovered_bound_credential_with_no_resolved_credentials() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
discovered_credentials=[_discovered("cred_safe", "https://login.example.test/login")],
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_workflow_yaml(navigation_goal="Log in."),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE not in verdict.reason_codes
|
|
|
|
|
|
def test_allows_two_discovered_bound_credentials_in_one_save() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
discovered_credentials=[
|
|
_discovered("cred_amazon", "https://login-a.authenticationtest.test/login"),
|
|
_discovered("cred_quicken", "https://login-b.authenticationtest.test/login"),
|
|
],
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_two_credential_workflow_yaml(),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
|
|
|
|
def test_allows_resolved_and_discovered_credentials_together_in_one_save() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[_credential("cred_amazon", "https://login-a.authenticationtest.test/login")],
|
|
discovered_credentials=[_discovered("cred_quicken", "https://login-b.authenticationtest.test/login")],
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_two_credential_workflow_yaml(),
|
|
)
|
|
|
|
assert verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE not in verdict.reason_codes
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE not in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_when_only_one_of_two_bound_credentials_is_discovered() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
discovered_credentials=[_discovered("cred_amazon", "https://login-a.authenticationtest.test/login")],
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_two_credential_workflow_yaml(),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_discovered_credential_id_that_is_not_bound_in_workflow() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
discovered_credentials=[_discovered("cred_safe", "https://login.example.test/login")],
|
|
credential_input_kind="none",
|
|
),
|
|
user_response="I used cred_safe for the login.",
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_fabricated_credential_id_not_in_discovered_set() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
discovered_credentials=[_discovered("cred_amazon", "https://login.example.test/login")],
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_workflow_yaml(navigation_goal="Log in."),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.UNAPPROVED_CREDENTIAL_REFERENCE in verdict.reason_codes
|
|
|
|
|
|
def test_rejects_discovered_credential_bound_to_new_origin() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(
|
|
resolved_credentials=[],
|
|
discovered_credentials=[_discovered("cred_safe", "https://login.example.test/login")],
|
|
credential_input_kind="none",
|
|
),
|
|
workflow_yaml=_workflow_yaml(url="https://evil.example.test/login"),
|
|
)
|
|
|
|
assert not verdict.allowed
|
|
assert OutputPolicyReason.CREDENTIAL_SCOPE_BROADENED in verdict.reason_codes
|
|
|
|
|
|
def _present_contract_confirmation_policy() -> RequestPolicy:
|
|
return _policy(
|
|
user_response_policy="proceed",
|
|
completion_contract_status="present",
|
|
completion_criteria=[
|
|
SimpleNamespace(id="record_identity", outcome="The returned record identifies the target record."),
|
|
],
|
|
)
|
|
|
|
|
|
def test_genuine_attempt_true_suppresses_avoidable_backstop() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_present_contract_confirmation_policy(),
|
|
response_type="ASK_QUESTION",
|
|
user_response="Please confirm the output fields before I build this record status workflow.",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=True,
|
|
)
|
|
|
|
assert OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION not in verdict.reason_codes
|
|
|
|
|
|
def test_genuine_attempt_true_suppresses_unbacked_delivery_claim() -> None:
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="Here's the workflow.",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=True,
|
|
)
|
|
|
|
assert OutputPolicyReason.UNBACKED_WORKFLOW_DELIVERY_CLAIM not in verdict.reason_codes
|
|
|
|
|
|
class _CaptureSentinel(Exception):
|
|
pass
|
|
|
|
|
|
def _run_id_only_ctx() -> CopilotContext:
|
|
ctx = _ctx()
|
|
ctx.last_run_blocks_workflow_run_id = "wr_discriminating"
|
|
ctx.last_update_block_count = None
|
|
ctx.last_test_ok = None
|
|
ctx.last_workflow = None
|
|
return ctx
|
|
|
|
|
|
def test_build_exit_result_passes_genuine_predicate_as_workflow_attempted(monkeypatch) -> None:
|
|
ctx = _run_id_only_ctx()
|
|
assert ctx.has_genuine_workflow_attempt() is True
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _capture_eval(**kwargs: Any) -> None:
|
|
captured["evaluate"] = kwargs["workflow_attempted"]
|
|
raise _CaptureSentinel
|
|
|
|
def _capture_kind(**kwargs: Any) -> None:
|
|
captured["derive"] = kwargs["workflow_attempted"]
|
|
return None
|
|
|
|
monkeypatch.setattr(agent_module, "derive_output_kind", _capture_kind)
|
|
monkeypatch.setattr(agent_module, "evaluate_output_policy", _capture_eval)
|
|
|
|
with pytest.raises(_CaptureSentinel):
|
|
agent_module._build_exit_result(ctx, user_response="Done.", global_llm_context=None)
|
|
|
|
assert captured["derive"] is True
|
|
assert captured["evaluate"] is True
|
|
assert captured["evaluate"] == ctx.has_genuine_workflow_attempt()
|
|
|
|
|
|
def test_watchdog_run_id_only_counts_as_genuine_and_suppresses_unbacked() -> None:
|
|
ctx = _run_id_only_ctx()
|
|
legacy_two_field_attempted = ctx.last_update_block_count is not None or ctx.last_test_ok is not None
|
|
assert legacy_two_field_attempted is False
|
|
assert ctx.has_genuine_workflow_attempt() is True
|
|
|
|
verdict = evaluate_output_policy(
|
|
request_policy=_policy(),
|
|
response_type="REPLY",
|
|
user_response="Here's the workflow.",
|
|
has_workflow_proposal=False,
|
|
workflow_attempted=ctx.has_genuine_workflow_attempt(),
|
|
)
|
|
|
|
assert OutputPolicyReason.UNBACKED_WORKFLOW_DELIVERY_CLAIM not in verdict.reason_codes
|
|
|
|
|
|
def test_agent_no_longer_derives_workflow_attempted_from_two_marker_fields() -> None:
|
|
source = inspect.getsource(agent_module)
|
|
assert "last_update_block_count is not None or ctx.last_test_ok is not None" not in source
|
|
assert "workflow_attempted=bool(ctx.last_run_blocks_workflow_run_id)" not in source
|
|
|
|
|
|
_AVOIDABLE_ASK = {
|
|
"type": "ASK_QUESTION",
|
|
"user_response": "Please confirm the output fields before I build this workflow.",
|
|
}
|
|
|
|
|
|
def _recoverable_authoring_intent(*, may_update_workflow: bool = True) -> TurnIntent:
|
|
return TurnIntent(
|
|
mode=TurnIntentMode.BUILD,
|
|
authority=TurnIntentAuthority(
|
|
may_update_workflow=may_update_workflow,
|
|
may_run_blocks=may_update_workflow,
|
|
requires_user_input=False,
|
|
),
|
|
)
|
|
|
|
|
|
def _present_contract_ask_policy(**overrides: object) -> RequestPolicy:
|
|
defaults: dict[str, object] = dict(
|
|
user_response_policy="proceed",
|
|
clarification_reason="none",
|
|
completion_contract_status="present",
|
|
completion_criteria=[
|
|
CompletionCriterion(id="record_id", outcome="The returned record includes the requested id."),
|
|
],
|
|
)
|
|
defaults.update(overrides)
|
|
return _policy(**defaults)
|
|
|
|
|
|
def _recoverable_ask_ctx(
|
|
*,
|
|
policy: RequestPolicy | None = None,
|
|
turn_intent: TurnIntent | None = None,
|
|
**marker: object,
|
|
) -> CopilotContext:
|
|
ctx = _ctx(
|
|
request_policy=policy or _present_contract_ask_policy(),
|
|
turn_intent=turn_intent or _recoverable_authoring_intent(),
|
|
)
|
|
for name, value in marker.items():
|
|
setattr(ctx, name, value)
|
|
return ctx
|
|
|
|
|
|
def test_seam_ordering_avoidable_ask_defers_then_recycle_fires() -> None:
|
|
ctx = _recoverable_ask_ctx()
|
|
|
|
verdict, response_type, diagnostics = agent_module._evaluate_copilot_final_output_policy(ctx, _AVOIDABLE_ASK)
|
|
|
|
assert response_type == "ASK_QUESTION"
|
|
assert verdict.allowed is True
|
|
assert diagnostics["deferred_to_recycle"] is True
|
|
assert diagnostics["deferred_reason_codes"] == [OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION.value]
|
|
assert diagnostics["final_output_policy_allowed"] is True
|
|
|
|
assert _response_coverage_nudge(ctx, _AVOIDABLE_ASK) == PRESENT_COMPLETION_CONTRACT_ASK_RETRY
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sdk_output_guardrail_defers_avoidable_ask_without_tripwire() -> None:
|
|
output_guardrails = agent_module._build_copilot_output_guardrails(OutputGuardrail, GuardrailFunctionOutput)
|
|
result = await output_guardrails[0].run(
|
|
RunContextWrapper(context=_recoverable_ask_ctx()),
|
|
SimpleNamespace(),
|
|
_AVOIDABLE_ASK,
|
|
)
|
|
|
|
assert result.output.tripwire_triggered is False
|
|
assert result.output.output_info["deferred_to_recycle"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("policy_overrides", "marker", "may_author", "expected_admit"),
|
|
[
|
|
pytest.param({}, {}, True, True, id="recoverable_admits"),
|
|
pytest.param({}, {"test_after_update_done": True}, True, True, id="scout_only_admits"),
|
|
pytest.param({}, {"last_test_ok": True}, True, False, id="genuine_test"),
|
|
pytest.param({}, {"last_run_blocks_workflow_run_id": "wr_1"}, True, False, id="genuine_run"),
|
|
pytest.param({"user_response_policy": "ask_clarification"}, {}, True, False, id="ask_clarification"),
|
|
pytest.param(
|
|
{"clarification_reason": "credential_name_unresolved"}, {}, True, False, id="clarification_reason"
|
|
),
|
|
pytest.param({}, {}, False, False, id="non_authoring"),
|
|
],
|
|
)
|
|
def test_avoidable_deferral_iff_recycle_admits(
|
|
policy_overrides: dict[str, object],
|
|
marker: dict[str, object],
|
|
may_author: bool,
|
|
expected_admit: bool,
|
|
) -> None:
|
|
ctx = _recoverable_ask_ctx(
|
|
policy=_present_contract_ask_policy(**policy_overrides),
|
|
turn_intent=_recoverable_authoring_intent(may_update_workflow=may_author),
|
|
**marker,
|
|
)
|
|
|
|
recycle_admits = _response_coverage_nudge(ctx, _AVOIDABLE_ASK) == PRESENT_COMPLETION_CONTRACT_ASK_RETRY
|
|
assert recycle_admits is expected_admit
|
|
|
|
_, _, diagnostics = agent_module._evaluate_copilot_final_output_policy(ctx, _AVOIDABLE_ASK)
|
|
assert diagnostics.get("deferred_to_recycle", False) is recycle_admits
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("policy_overrides", "may_author"),
|
|
[
|
|
pytest.param({"clarification_reason": "credential_name_unresolved"}, True, id="clarification_reason"),
|
|
pytest.param({}, False, id="non_authoring"),
|
|
],
|
|
)
|
|
def test_backstop_trips_as_failsafe_when_recycle_does_not_admit(
|
|
policy_overrides: dict[str, object],
|
|
may_author: bool,
|
|
) -> None:
|
|
ctx = _recoverable_ask_ctx(
|
|
policy=_present_contract_ask_policy(**policy_overrides),
|
|
turn_intent=_recoverable_authoring_intent(may_update_workflow=may_author),
|
|
)
|
|
|
|
verdict, _, diagnostics = agent_module._evaluate_copilot_final_output_policy(ctx, _AVOIDABLE_ASK)
|
|
|
|
assert verdict.allowed is False
|
|
assert OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION in verdict.reason_codes
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|
|
|
|
|
|
def test_defer_avoidable_ask_requires_ask_shape_and_sole_avoidable_code() -> None:
|
|
ctx = _recoverable_ask_ctx()
|
|
avoidable = OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION
|
|
|
|
assert agent_module._defer_avoidable_ask_to_recycle(
|
|
ctx, OutputPolicyVerdict(reason_codes=[avoidable]), "ASK_QUESTION"
|
|
) == [avoidable]
|
|
|
|
mixed = OutputPolicyVerdict(reason_codes=[avoidable, OutputPolicyReason.RAW_SECRET_LEAK])
|
|
assert agent_module._defer_avoidable_ask_to_recycle(ctx, mixed, "ASK_QUESTION") is None
|
|
|
|
assert (
|
|
agent_module._defer_avoidable_ask_to_recycle(ctx, OutputPolicyVerdict(reason_codes=[avoidable]), "REPLY")
|
|
is None
|
|
)
|
|
|
|
wrong = OutputPolicyVerdict(reason_codes=[OutputPolicyReason.PERSISTENCE_STATE_MISMATCH])
|
|
assert agent_module._defer_avoidable_ask_to_recycle(ctx, wrong, "ASK_QUESTION") is None
|
|
|
|
assert agent_module._defer_avoidable_ask_to_recycle(ctx, OutputPolicyVerdict(), "ASK_QUESTION") is None
|
|
|
|
|
|
def test_avoidable_ask_with_co_firing_secret_leak_is_not_deferred() -> None:
|
|
ctx = _recoverable_ask_ctx()
|
|
ask = {
|
|
"type": "ASK_QUESTION",
|
|
"user_response": (
|
|
"Please confirm the output fields before I build this workflow. I used password: hunter2 to test the login."
|
|
),
|
|
}
|
|
|
|
verdict, _, diagnostics = agent_module._evaluate_copilot_final_output_policy(ctx, ask)
|
|
|
|
assert verdict.allowed is False
|
|
assert OutputPolicyReason.AVOIDABLE_OUTPUT_FIELD_CONFIRMATION in verdict.reason_codes
|
|
assert OutputPolicyReason.RAW_SECRET_LEAK in verdict.reason_codes
|
|
assert diagnostics.get("deferred_to_recycle", False) is False
|