510 lines
17 KiB
Python
510 lines
17 KiB
Python
"""
|
|
Integration test: ``LEVEL_READ`` callers get policy verdicts without session mutation.
|
|
|
|
Uses a real ``SqlAlchemyPermissionStore`` so ``UnifiedAuthProvider``
|
|
resolves ``X-Forwarded-Email`` headers to permission levels. An owner
|
|
creates a session with a label-writing policy, then a ``LEVEL_READ``
|
|
collaborator evaluates the same session. The collaborator receives the
|
|
verdict (ALLOW with ``set_labels`` populated), but the session's
|
|
persisted labels are unchanged afterward. A second test confirms that
|
|
an ``LEVEL_EDIT`` caller still persists as before.
|
|
|
|
Uses the shared ``mock_llm`` / ``runtime_init`` / ``db_uri`` fixtures
|
|
from ``tests/server/conftest.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.runtime import get_caps
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.runtime.caps import RuntimeCaps
|
|
from omnigent.server.app import create_app
|
|
from omnigent.server.auth import LEVEL_EDIT, LEVEL_READ
|
|
from omnigent.spec.types import FunctionPolicySpec, FunctionRef, Phase
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
|
from tests.server.conftest import ControllableMockClient
|
|
from tests.server.helpers import create_test_agent
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
# ── Policy callable ──────────────────────────────────────────────
|
|
|
|
|
|
def _allow_and_label(event: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Policy that ALLOWs every event and writes a label.
|
|
|
|
Always returns ``set_labels: {"evaluated": "true"}`` so we can
|
|
verify whether the label was actually persisted.
|
|
|
|
:param event: V0 event dict.
|
|
:returns: ALLOW with ``set_labels``.
|
|
"""
|
|
return {
|
|
"result": "ALLOW",
|
|
"set_labels": {"evaluated": "true"},
|
|
}
|
|
|
|
|
|
def _ask_on_request(event: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Policy that demands approval (ASK) for every event.
|
|
|
|
Used to exercise the endpoint's server-side ASK park on the REQUEST
|
|
phase — the phase a native session's ``UserPromptSubmit`` hook hits.
|
|
|
|
:param event: V0 event dict.
|
|
:returns: ASK with a reason.
|
|
"""
|
|
return {
|
|
"result": "ASK",
|
|
"reason": "Prompt requires approval",
|
|
}
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
|
|
OWNER = "owner@example.com"
|
|
READER = "reader@example.com"
|
|
EDITOR = "editor@example.com"
|
|
|
|
|
|
def _tool_call_request(tool_name: str = "Bash") -> dict[str, Any]:
|
|
"""
|
|
Build a PHASE_TOOL_CALL EvaluationRequest.
|
|
|
|
:param tool_name: Tool name, e.g. ``"Bash"``.
|
|
:returns: EvaluationRequest JSON dict.
|
|
"""
|
|
return {
|
|
"event": {
|
|
"type": "PHASE_TOOL_CALL",
|
|
"target": "",
|
|
"data": {"name": tool_name, "arguments": {}},
|
|
"context": {},
|
|
},
|
|
}
|
|
|
|
|
|
def _request_request(text: str = "do the thing") -> dict[str, Any]:
|
|
"""
|
|
Build a PHASE_REQUEST EvaluationRequest (the UserPromptSubmit shape).
|
|
|
|
:param text: The user prompt text.
|
|
:returns: EvaluationRequest JSON dict.
|
|
"""
|
|
return {
|
|
"event": {
|
|
"type": "PHASE_REQUEST",
|
|
"target": "",
|
|
"data": {"text": text},
|
|
"context": {},
|
|
},
|
|
}
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture()
|
|
def auth_app(
|
|
runtime_init: None,
|
|
db_uri: str,
|
|
tmp_path: Path,
|
|
) -> FastAPI:
|
|
"""
|
|
App with ``permission_store`` enabled so auth is active.
|
|
|
|
:param runtime_init: Fixture that initializes the runtime with a mock LLM.
|
|
:param db_uri: Per-test SQLite URI.
|
|
:param tmp_path: Pytest temp dir for artifacts.
|
|
:returns: A :class:`FastAPI` instance with auth and policy routes active.
|
|
"""
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
return create_app(
|
|
agent_store=SqlAlchemyAgentStore(db_uri),
|
|
file_store=SqlAlchemyFileStore(db_uri),
|
|
conversation_store=SqlAlchemyConversationStore(db_uri),
|
|
artifact_store=artifact_store,
|
|
agent_cache=AgentCache(
|
|
artifact_store=artifact_store,
|
|
cache_dir=tmp_path / "cache",
|
|
),
|
|
comment_store=SqlAlchemyCommentStore(db_uri),
|
|
permission_store=SqlAlchemyPermissionStore(db_uri),
|
|
auth_provider=UnifiedAuthProvider(source="header"),
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture()
|
|
async def auth_client(
|
|
auth_app: FastAPI,
|
|
mock_llm: ControllableMockClient,
|
|
tmp_path: Path,
|
|
) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""
|
|
Async HTTP client wired to the auth-enabled app.
|
|
|
|
:param auth_app: FastAPI app with permission store.
|
|
:param mock_llm: Controllable mock LLM -- released on teardown.
|
|
:param tmp_path: Pytest temp dir for the harness process manager.
|
|
:yields: A ready-to-use :class:`httpx.AsyncClient`.
|
|
"""
|
|
from omnigent.runtime import set_harness_process_manager
|
|
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
|
|
|
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
|
|
await pm.start()
|
|
set_harness_process_manager(pm)
|
|
|
|
transport = httpx.ASGITransport(app=auth_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
mock_llm.release_all()
|
|
set_harness_process_manager(None)
|
|
await pm.shutdown()
|
|
|
|
|
|
async def _create_session_as(
|
|
client: httpx.AsyncClient,
|
|
user: str,
|
|
agent_id: str,
|
|
) -> str:
|
|
"""
|
|
Create a session via the API as the given user.
|
|
|
|
:param client: Test HTTP client.
|
|
:param user: User email for ``X-Forwarded-Email``.
|
|
:param agent_id: Agent to bind.
|
|
:returns: New session id.
|
|
"""
|
|
resp = await client.post(
|
|
"/v1/sessions",
|
|
json={"agent_id": agent_id},
|
|
headers={"X-Forwarded-Email": user},
|
|
)
|
|
assert resp.status_code == 201, f"create session failed: {resp.status_code} {resp.text}"
|
|
return resp.json()["id"]
|
|
|
|
|
|
def _grant_access(db_uri: str, user: str, session_id: str, level: int) -> None:
|
|
"""
|
|
Grant a permission level to a user on a session.
|
|
|
|
:param db_uri: SQLite connection URI.
|
|
:param user: User email.
|
|
:param session_id: Session (conversation) id.
|
|
:param level: Permission level, e.g. ``LEVEL_READ``.
|
|
"""
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
perm_store.ensure_user(user)
|
|
perm_store.grant(user, session_id, level)
|
|
|
|
|
|
def _get_labels(db_uri: str, session_id: str) -> dict[str, str]:
|
|
"""
|
|
Read persisted labels from the conversation store.
|
|
|
|
:param db_uri: SQLite connection URI.
|
|
:param session_id: Session (conversation) id.
|
|
:returns: The conversation's label dict.
|
|
"""
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
conv = conv_store.get_conversation(session_id)
|
|
assert conv is not None, f"session {session_id!r} not found"
|
|
return dict(conv.labels)
|
|
|
|
|
|
# ── Tests ────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_read_only_caller_gets_verdict_but_no_label_mutation(
|
|
auth_client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""
|
|
A LEVEL_READ collaborator receives the policy verdict but session
|
|
labels are NOT persisted.
|
|
|
|
The label-writing policy returns ``set_labels: {"evaluated": "true"}``.
|
|
The LEVEL_READ caller sees ALLOW in the response, but the session's
|
|
persisted ``conversation_labels`` remains empty afterward. This
|
|
verifies the ``read_only`` guard in the route and the engine.
|
|
"""
|
|
labeling_policy = FunctionPolicySpec(
|
|
name="admin__labeler",
|
|
on=None,
|
|
function=FunctionRef(path=f"{__name__}._allow_and_label"),
|
|
)
|
|
original_caps = get_caps()
|
|
patched_caps = RuntimeCaps(
|
|
execution_timeout=original_caps.execution_timeout,
|
|
default_policies=[labeling_policy],
|
|
)
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions.get_caps",
|
|
lambda: patched_caps,
|
|
)
|
|
|
|
# Owner creates the agent and session.
|
|
agent = await create_test_agent(auth_client, user=OWNER)
|
|
session_id = await _create_session_as(auth_client, OWNER, agent["id"])
|
|
|
|
# Grant LEVEL_READ to reader.
|
|
_grant_access(db_uri, READER, session_id, LEVEL_READ)
|
|
|
|
# Snapshot labels before.
|
|
labels_before = _get_labels(db_uri, session_id)
|
|
assert labels_before.get("evaluated") is None, "label should not exist before evaluation"
|
|
|
|
# Reader evaluates — should get ALLOW but no persistence.
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies/evaluate",
|
|
json=_tool_call_request("Read"),
|
|
headers={"X-Forwarded-Email": READER},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["result"] == "POLICY_ACTION_ALLOW"
|
|
|
|
# Labels must NOT have been persisted.
|
|
labels_after = _get_labels(db_uri, session_id)
|
|
assert labels_after.get("evaluated") is None, (
|
|
"LEVEL_READ caller must not mutate session labels via policy evaluate"
|
|
)
|
|
|
|
|
|
async def test_edit_caller_persists_labels_as_before(
|
|
auth_client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""
|
|
A LEVEL_EDIT (or higher) caller's policy evaluation still persists labels.
|
|
|
|
Ensures the ``read_only`` guard only fires for sub-edit levels and
|
|
does not regress the normal write path.
|
|
"""
|
|
labeling_policy = FunctionPolicySpec(
|
|
name="admin__labeler",
|
|
on=None,
|
|
function=FunctionRef(path=f"{__name__}._allow_and_label"),
|
|
)
|
|
original_caps = get_caps()
|
|
patched_caps = RuntimeCaps(
|
|
execution_timeout=original_caps.execution_timeout,
|
|
default_policies=[labeling_policy],
|
|
)
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions.get_caps",
|
|
lambda: patched_caps,
|
|
)
|
|
|
|
# Owner creates the agent and session.
|
|
agent = await create_test_agent(auth_client, user=OWNER)
|
|
session_id = await _create_session_as(auth_client, OWNER, agent["id"])
|
|
|
|
# Grant LEVEL_EDIT to editor.
|
|
_grant_access(db_uri, EDITOR, session_id, LEVEL_EDIT)
|
|
|
|
# Editor evaluates — should persist labels.
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies/evaluate",
|
|
json=_tool_call_request("Read"),
|
|
headers={"X-Forwarded-Email": EDITOR},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["result"] == "POLICY_ACTION_ALLOW"
|
|
|
|
# Labels SHOULD have been persisted.
|
|
labels_after = _get_labels(db_uri, session_id)
|
|
assert labels_after.get("evaluated") == "true", (
|
|
"LEVEL_EDIT caller's policy evaluation must still persist labels"
|
|
)
|
|
|
|
|
|
async def test_request_phase_ask_parks_server_side_and_collapses_to_allow(
|
|
auth_client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""
|
|
A REQUEST-phase ASK is parked server-side and collapses to a hard verdict.
|
|
|
|
This is the path a native session's ``UserPromptSubmit`` hook takes: it
|
|
POSTs a PHASE_REQUEST event and must NEVER see raw ASK (the hook has no
|
|
ASK primitive — it can only block or allow). The endpoint holds the gate
|
|
via ``_hold_native_ask_gate`` and returns ALLOW on approve / DENY on
|
|
decline. Before REQUEST was added to the endpoint's ASK-park phases, this
|
|
returned a raw ``POLICY_ACTION_ASK`` the hook couldn't act on safely.
|
|
"""
|
|
held: dict[str, Any] = {}
|
|
|
|
async def _fake_hold(_request: Any, **kwargs: Any) -> bool:
|
|
held["phase"] = kwargs["phase"]
|
|
return True # human approved
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions._hold_native_ask_gate",
|
|
_fake_hold,
|
|
)
|
|
ask_policy = FunctionPolicySpec(
|
|
name="admin__ask",
|
|
on=None,
|
|
function=FunctionRef(path=f"{__name__}._ask_on_request"),
|
|
)
|
|
original_caps = get_caps()
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions.get_caps",
|
|
lambda: RuntimeCaps(
|
|
execution_timeout=original_caps.execution_timeout,
|
|
default_policies=[ask_policy],
|
|
),
|
|
)
|
|
|
|
agent = await create_test_agent(auth_client, user=OWNER)
|
|
session_id = await _create_session_as(auth_client, OWNER, agent["id"])
|
|
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies/evaluate",
|
|
json=_request_request("delete prod"),
|
|
headers={"X-Forwarded-Email": OWNER},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
# The gate was entered AT the REQUEST phase (not skipped as a raw ASK).
|
|
assert held.get("phase") == Phase.REQUEST
|
|
# Approval collapses the ASK to a hard ALLOW — the hook never sees ASK.
|
|
assert resp.json()["result"] == "POLICY_ACTION_ALLOW"
|
|
|
|
|
|
async def test_request_phase_ask_decline_collapses_to_deny(
|
|
auth_client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""
|
|
A declined / timed-out REQUEST-phase ASK collapses to DENY (fail closed).
|
|
|
|
The companion to the approve case: when the human declines (or the park
|
|
times out / disconnects), the endpoint returns DENY so the native hook
|
|
blocks the prompt rather than letting it through.
|
|
"""
|
|
|
|
async def _fake_hold(_request: Any, **_kwargs: Any) -> bool:
|
|
return False # declined / timeout
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions._hold_native_ask_gate",
|
|
_fake_hold,
|
|
)
|
|
ask_policy = FunctionPolicySpec(
|
|
name="admin__ask",
|
|
on=None,
|
|
function=FunctionRef(path=f"{__name__}._ask_on_request"),
|
|
)
|
|
original_caps = get_caps()
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions.get_caps",
|
|
lambda: RuntimeCaps(
|
|
execution_timeout=original_caps.execution_timeout,
|
|
default_policies=[ask_policy],
|
|
),
|
|
)
|
|
|
|
agent = await create_test_agent(auth_client, user=OWNER)
|
|
session_id = await _create_session_as(auth_client, OWNER, agent["id"])
|
|
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies/evaluate",
|
|
json=_request_request("delete prod"),
|
|
headers={"X-Forwarded-Email": OWNER},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["result"] == "POLICY_ACTION_DENY"
|
|
|
|
|
|
async def test_request_phase_skips_gate_when_web_prompt_pending(
|
|
auth_client: httpx.AsyncClient,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""
|
|
A REQUEST-phase eval is skipped (ALLOW) when a web prompt is in flight.
|
|
|
|
A native session's ``UserPromptSubmit`` hook posts ``PHASE_REQUEST`` for
|
|
every prompt, but a web-UI prompt was already gated server-side by
|
|
``_evaluate_input_policy`` before injection. The presence of a
|
|
``pending_inputs`` entry marks the prompt as web-origin, so the endpoint
|
|
short-circuits to ALLOW rather than re-gating (which would double-prompt
|
|
the human). If the dedup regressed, the ASK policy below would enter the
|
|
gate instead of returning a clean ALLOW.
|
|
"""
|
|
from omnigent.runtime import pending_inputs
|
|
|
|
# If the dedup is bypassed and the gate runs, this records the failure.
|
|
gate_ran = {"called": False}
|
|
|
|
async def _fail_hold(_request: Any, **_kwargs: Any) -> bool:
|
|
gate_ran["called"] = True
|
|
return True
|
|
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions._hold_native_ask_gate",
|
|
_fail_hold,
|
|
)
|
|
ask_policy = FunctionPolicySpec(
|
|
name="admin__ask",
|
|
on=None,
|
|
function=FunctionRef(path=f"{__name__}._ask_on_request"),
|
|
)
|
|
original_caps = get_caps()
|
|
monkeypatch.setattr(
|
|
"omnigent.server.routes.sessions.get_caps",
|
|
lambda: RuntimeCaps(
|
|
execution_timeout=original_caps.execution_timeout,
|
|
default_policies=[ask_policy],
|
|
),
|
|
)
|
|
|
|
agent = await create_test_agent(auth_client, user=OWNER)
|
|
session_id = await _create_session_as(auth_client, OWNER, agent["id"])
|
|
|
|
# Mark a web-composer prompt as in flight (recorded at POST /events for a
|
|
# native session, before the runner forward).
|
|
pending_inputs.record(session_id, [{"type": "input_text", "text": "delete prod"}])
|
|
try:
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/policies/evaluate",
|
|
json=_request_request("delete prod"),
|
|
headers={"X-Forwarded-Email": OWNER},
|
|
)
|
|
finally:
|
|
pending_inputs.reset_for_tests()
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
# Skipped → clean ALLOW, and the ASK gate was never entered.
|
|
assert resp.json()["result"] == "POLICY_ACTION_ALLOW"
|
|
assert gate_ran["called"] is False, "dedup must skip the gate when a web prompt is pending"
|