184 lines
6.1 KiB
Python
184 lines
6.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""UT for approval scope plumbing (EXACT vs SIMILAR).
|
|
|
|
Covers the new ``ApprovalScope`` enum, ``ApprovalService.resolve_request``
|
|
staking scope on the pending record, and ``approval_display_fields``
|
|
surfacing the console gating fields.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
import asyncio
|
|
|
|
from pineagents.app.approvals.display import approval_display_fields
|
|
from pineagents.app.approvals.service import ApprovalService, PendingApproval
|
|
from pineagents.security.tool_guard.approval import (
|
|
ApprovalDecision,
|
|
ApprovalScope,
|
|
)
|
|
from pineagents.security.tool_guard.models import (
|
|
GuardSeverity,
|
|
ToolGuardResult,
|
|
)
|
|
|
|
|
|
def _make_pending(request_id: str = "req-1") -> PendingApproval:
|
|
"""Build a PendingApproval directly (bypasses create_pending)."""
|
|
loop = asyncio.get_event_loop()
|
|
return PendingApproval(
|
|
request_id=request_id,
|
|
session_id="s",
|
|
root_session_id="s",
|
|
owner_agent_id="a",
|
|
user_id="u",
|
|
channel="console",
|
|
agent_id="a",
|
|
tool_name="Bash",
|
|
created_at=0.0,
|
|
future=loop.create_future(),
|
|
)
|
|
|
|
|
|
class TestApprovalScopeEnum:
|
|
"""ApprovalScope enum basics."""
|
|
|
|
def test_values(self):
|
|
assert ApprovalScope.EXACT.value == "exact"
|
|
assert ApprovalScope.SIMILAR.value == "similar"
|
|
|
|
def test_is_str_enum(self):
|
|
assert ApprovalScope.SIMILAR == "similar"
|
|
assert isinstance(ApprovalScope.EXACT, str)
|
|
|
|
def test_construct_from_string(self):
|
|
"""Router/command paths build the enum from a user-supplied string."""
|
|
assert ApprovalScope("exact") is ApprovalScope.EXACT
|
|
assert ApprovalScope("similar") is ApprovalScope.SIMILAR
|
|
|
|
|
|
class TestResolveRequestStashesScope:
|
|
"""resolve_request writes scope onto the pending record so the
|
|
governance consumer (tool_adapter) can pick the rule target."""
|
|
|
|
async def test_similar_scope_is_stashed(self):
|
|
svc = ApprovalService()
|
|
pending = _make_pending()
|
|
svc._pending[pending.request_id] = pending
|
|
|
|
resolved = await svc.resolve_request(
|
|
pending.request_id,
|
|
ApprovalDecision.APPROVED,
|
|
scope=ApprovalScope.SIMILAR,
|
|
)
|
|
|
|
assert resolved is pending
|
|
assert resolved.scope is ApprovalScope.SIMILAR
|
|
# The future is resolved with the plain decision (scope is orthogonal).
|
|
assert resolved.future.result() is ApprovalDecision.APPROVED
|
|
|
|
async def test_none_scope_defaults_to_exact_downstream(self):
|
|
"""No scope passed (IM channels / CLI) → scope stays None; the
|
|
consumer treats None as EXACT. Pending is still resolved."""
|
|
svc = ApprovalService()
|
|
pending = _make_pending()
|
|
svc._pending[pending.request_id] = pending
|
|
|
|
resolved = await svc.resolve_request(
|
|
pending.request_id,
|
|
ApprovalDecision.APPROVED,
|
|
)
|
|
|
|
assert resolved.scope is None
|
|
assert resolved.future.result() is ApprovalDecision.APPROVED
|
|
|
|
async def test_exact_scope_stashed(self):
|
|
svc = ApprovalService()
|
|
pending = _make_pending()
|
|
svc._pending[pending.request_id] = pending
|
|
|
|
resolved = await svc.resolve_request(
|
|
pending.request_id,
|
|
ApprovalDecision.APPROVED,
|
|
scope=ApprovalScope.EXACT,
|
|
)
|
|
assert resolved.scope is ApprovalScope.EXACT
|
|
|
|
async def test_scope_ignored_on_deny(self):
|
|
"""Scope is meaningless for a denial, but resolve still accepts it
|
|
without error and resolves the future as DENIED."""
|
|
svc = ApprovalService()
|
|
pending = _make_pending()
|
|
svc._pending[pending.request_id] = pending
|
|
|
|
resolved = await svc.resolve_request(
|
|
pending.request_id,
|
|
ApprovalDecision.DENIED,
|
|
scope=ApprovalScope.SIMILAR,
|
|
)
|
|
assert resolved.future.result() is ApprovalDecision.DENIED
|
|
|
|
|
|
class _FakePending:
|
|
"""Minimal stand-in exposing ``extra`` + ``tool_name`` for display."""
|
|
|
|
def __init__(self, display: dict | None) -> None:
|
|
self.extra = {"display": display} if display is not None else {}
|
|
self.tool_name = "Bash"
|
|
|
|
|
|
class TestApprovalDisplayFields:
|
|
"""approval_display_fields surfaces the scope-choice gating fields."""
|
|
|
|
def test_generalized_targets_surface(self):
|
|
pending = _FakePending(
|
|
{
|
|
"tool_name": "Bash",
|
|
"tool_source": "No rule hit",
|
|
"exact_target": "git status",
|
|
"similar_target": "git *",
|
|
"is_generalized": True,
|
|
},
|
|
)
|
|
fields = approval_display_fields(pending)
|
|
assert fields["exact_target"] == "git status"
|
|
assert fields["similar_target"] == "git *"
|
|
assert fields["is_generalized"] is True
|
|
assert fields["tool_display_name"] == "Bash"
|
|
|
|
def test_not_generalized(self):
|
|
pending = _FakePending(
|
|
{
|
|
"tool_name": "Bash",
|
|
"tool_source": "builtin_rules",
|
|
"exact_target": "git status",
|
|
"similar_target": "git status",
|
|
"is_generalized": False,
|
|
},
|
|
)
|
|
fields = approval_display_fields(pending)
|
|
assert fields["is_generalized"] is False
|
|
|
|
def test_missing_display_defaults_safely(self):
|
|
"""No display dict at all → safe defaults, is_generalized False."""
|
|
pending = _FakePending(None)
|
|
fields = approval_display_fields(pending)
|
|
assert fields["is_generalized"] is False
|
|
assert fields["exact_target"] == ""
|
|
assert fields["similar_target"] == ""
|
|
assert fields["tool_display_name"] == "Bash"
|
|
assert fields["tool_source"] == "No rule hit"
|
|
|
|
|
|
class TestToolGuardResultUnchanged:
|
|
"""Sanity: ToolGuardResult still constructs (display plumbing imports)."""
|
|
|
|
def test_construct(self):
|
|
result = ToolGuardResult(
|
|
tool_name="Bash",
|
|
params={},
|
|
findings=[],
|
|
)
|
|
assert result.findings_count == 0
|
|
assert result.max_severity == GuardSeverity.SAFE
|