294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Permission gating for the environment ``/shell`` proxy endpoint.
|
|
|
|
A shared session's shell runs commands on the runner. When the runner is
|
|
not isolated (``sandbox_active: false``), that shell can read files the
|
|
session owner can reach — so write-capable shell access must be gated the
|
|
same way interactive terminal attach is (see ``test_terminal_attach.py``).
|
|
|
|
The shell proxy at
|
|
``POST /v1/sessions/{id}/resources/environments/{env}/shell`` runs
|
|
``_validate_session(required_level=LEVEL_EDIT)`` *before* proxying. These
|
|
tests pin that gate end to end at the server boundary:
|
|
|
|
- a read-only collaborator is rejected with 403 and the request never
|
|
reaches the runner (decisive: the secret-capable shell is unreachable),
|
|
- an edit collaborator is allowed through and the command is proxied,
|
|
- an unauthenticated caller is rejected.
|
|
|
|
The deeper gap — an *edit* collaborator on an unsafe runner reading
|
|
out-of-root/sensitive files via shell — is pinned by
|
|
the strict-xfail matrix in ``test_filesystem_path_isolation_e2e.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from omnigent.entities import Conversation, ResolvedAccess, SessionPermission
|
|
from omnigent.errors import OmnigentError
|
|
from omnigent.runtime import _globals, set_runner_client, set_runner_router
|
|
from omnigent.server.auth import (
|
|
LEVEL_EDIT,
|
|
LEVEL_OWNER,
|
|
LEVEL_READ,
|
|
RESERVED_USER_PUBLIC,
|
|
UnifiedAuthProvider,
|
|
)
|
|
from omnigent.server.routes.sessions import create_sessions_router
|
|
|
|
# The server route is mounted under /v1 and proxies to the runner at the
|
|
# same path, so the client URL and the recorded runner path are identical.
|
|
_SHELL_PATH = "/v1/sessions/conv_share/resources/environments/default/shell"
|
|
|
|
|
|
class _StubConversationStore:
|
|
"""In-memory conversation store exposing ``get_conversation``."""
|
|
|
|
def __init__(self) -> None:
|
|
self._conversations: dict[str, Conversation] = {}
|
|
|
|
def get_conversation(self, conversation_id: str) -> Conversation | None:
|
|
return self._conversations.get(conversation_id)
|
|
|
|
def add(self, conversation_id: str) -> None:
|
|
self._conversations[conversation_id] = Conversation(
|
|
id=conversation_id,
|
|
created_at=0,
|
|
updated_at=0,
|
|
root_conversation_id=conversation_id,
|
|
agent_id="ag_test",
|
|
)
|
|
|
|
|
|
class _StubPermissionStore:
|
|
"""In-memory permission store with the methods access checks use."""
|
|
|
|
def __init__(self) -> None:
|
|
self._grants: dict[tuple[str, str], SessionPermission] = {}
|
|
self._admins: set[str] = set()
|
|
|
|
def get(self, user_id: str, conversation_id: str) -> SessionPermission | None:
|
|
return self._grants.get((user_id, conversation_id))
|
|
|
|
def is_admin(self, user_id: str) -> bool:
|
|
return user_id in self._admins
|
|
|
|
def add_grant(self, user_id: str, conversation_id: str, level: int) -> None:
|
|
self._grants[(user_id, conversation_id)] = SessionPermission(
|
|
user_id=user_id,
|
|
conversation_id=conversation_id,
|
|
level=level,
|
|
)
|
|
|
|
def check_access(self, user_id: str | None, conversation_id: str, required_level: int) -> bool:
|
|
if user_id is None:
|
|
return False
|
|
grant = self.get(user_id, conversation_id)
|
|
if grant is not None and grant.level >= required_level:
|
|
return True
|
|
public_grant = self.get(RESERVED_USER_PUBLIC, conversation_id)
|
|
if public_grant is not None and public_grant.level >= required_level:
|
|
return True
|
|
return False
|
|
|
|
def get_permission_level(self, user_id: str | None, conversation_id: str) -> int | None:
|
|
if user_id is None:
|
|
return None
|
|
if self.is_admin(user_id):
|
|
return LEVEL_OWNER
|
|
grant = self.get(user_id, conversation_id)
|
|
if grant is not None:
|
|
return grant.level
|
|
public_grant = self.get(RESERVED_USER_PUBLIC, conversation_id)
|
|
if public_grant is not None:
|
|
return public_grant.level
|
|
return None
|
|
|
|
def resolve_access(self, user_id: str | None, conversation_id: str) -> ResolvedAccess:
|
|
# Mirror the real store's single-round-trip resolution: admin flag
|
|
# plus the user's and public grants, with resolution policy
|
|
# (admin bypass, public fallback) left to the server's
|
|
# ``resolved_allows`` / ``resolved_level`` helpers.
|
|
if user_id is None:
|
|
return ResolvedAccess(
|
|
is_admin=False,
|
|
user_grant_level=None,
|
|
public_grant_level=None,
|
|
)
|
|
user_grant = self.get(user_id, conversation_id)
|
|
public_grant = self.get(RESERVED_USER_PUBLIC, conversation_id)
|
|
return ResolvedAccess(
|
|
is_admin=self.is_admin(user_id),
|
|
user_grant_level=user_grant.level if user_grant is not None else None,
|
|
public_grant_level=public_grant.level if public_grant is not None else None,
|
|
)
|
|
|
|
|
|
class _StubAgentStore:
|
|
def get(self, agent_id: str) -> None:
|
|
return None
|
|
|
|
|
|
class _RecordingRunnerClient:
|
|
"""Runner client that records POSTs and returns a canned shell result.
|
|
|
|
A POST reaching here means the permission gate let the request through —
|
|
the tests assert this list stays empty for rejected callers.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.posts: list[tuple[str, Any]] = []
|
|
|
|
async def post(
|
|
self,
|
|
url: str,
|
|
*,
|
|
json: Any = None,
|
|
timeout: float | None = None,
|
|
) -> httpx.Response:
|
|
del timeout
|
|
self.posts.append((url, json))
|
|
return httpx.Response(
|
|
status_code=200,
|
|
json={
|
|
"object": "session.environment.shell_result",
|
|
"stdout": "ok\n",
|
|
"stderr": "",
|
|
"exit_code": 0,
|
|
"timed_out": False,
|
|
"cwd": "/workspace",
|
|
},
|
|
request=httpx.Request("POST", url),
|
|
)
|
|
|
|
|
|
class _RoutedRunner:
|
|
def __init__(self, client: _RecordingRunnerClient) -> None:
|
|
self.runner_id = "runner_one"
|
|
self.client = client
|
|
|
|
|
|
class _FakeRunnerRouter:
|
|
def __init__(self, client: _RecordingRunnerClient) -> None:
|
|
self.client = client
|
|
|
|
def client_for_session_resources(self, session_id: str) -> _RoutedRunner:
|
|
return _RoutedRunner(self.client)
|
|
|
|
|
|
@pytest.fixture
|
|
def runner_globals_reset() -> Iterator[None]:
|
|
prior_client = _globals._runner_client
|
|
prior_router = _globals._runner_router
|
|
set_runner_client(None)
|
|
set_runner_router(None)
|
|
yield
|
|
set_runner_client(prior_client)
|
|
set_runner_router(prior_router)
|
|
|
|
|
|
@pytest.fixture
|
|
def runner_client() -> _RecordingRunnerClient:
|
|
return _RecordingRunnerClient()
|
|
|
|
|
|
@pytest.fixture
|
|
def app(runner_globals_reset: None, runner_client: _RecordingRunnerClient) -> FastAPI:
|
|
del runner_globals_reset
|
|
conv_store = _StubConversationStore()
|
|
conv_store.add("conv_share")
|
|
perm_store = _StubPermissionStore()
|
|
perm_store.add_grant("owner@example.com", "conv_share", LEVEL_EDIT)
|
|
perm_store.add_grant("viewer@example.com", "conv_share", LEVEL_READ)
|
|
set_runner_router(_FakeRunnerRouter(runner_client)) # type: ignore[arg-type]
|
|
|
|
application = FastAPI()
|
|
|
|
@application.exception_handler(OmnigentError)
|
|
async def _handle_omnigent_error(
|
|
request: Request,
|
|
exc: OmnigentError,
|
|
) -> JSONResponse:
|
|
del request
|
|
return JSONResponse(
|
|
status_code=exc.http_status,
|
|
content={"error": {"code": exc.code, "message": exc.message}},
|
|
)
|
|
|
|
application.include_router(
|
|
create_sessions_router(
|
|
conv_store, # type: ignore[arg-type]
|
|
_StubAgentStore(), # type: ignore[arg-type]
|
|
# local_single_user=False: this suite verifies the strict
|
|
# (deployed multi-user) posture where a headerless request is
|
|
# rejected, so opt out of the suite-wide single-user default
|
|
# set in tests/conftest.py (OMNIGENT_LOCAL_SINGLE_USER=1).
|
|
auth_provider=UnifiedAuthProvider(source="header", local_single_user=False),
|
|
permission_store=perm_store, # type: ignore[arg-type]
|
|
),
|
|
prefix="/v1",
|
|
)
|
|
return application
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://server") as c:
|
|
yield c
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_rejects_read_only_collaborator_before_runner(
|
|
client: httpx.AsyncClient,
|
|
runner_client: _RecordingRunnerClient,
|
|
) -> None:
|
|
"""A read-only collaborator cannot run shell, and the runner is never hit."""
|
|
resp = await client.post(
|
|
_SHELL_PATH,
|
|
json={"command": "cat ~/.ssh/id_rsa"},
|
|
headers={"X-Forwarded-Email": "viewer@example.com"},
|
|
)
|
|
assert resp.status_code == 403, resp.text
|
|
# Decisive: the command never reached the runner's (unconfined) shell.
|
|
assert runner_client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_rejects_unauthenticated_before_runner(
|
|
client: httpx.AsyncClient,
|
|
runner_client: _RecordingRunnerClient,
|
|
) -> None:
|
|
"""Unauthenticated shell exec is rejected.
|
|
|
|
Without ``X-Forwarded-Email``, strict header mode fails closed
|
|
with 401 — the request never resolves to a user,
|
|
let alone reaches the permission check or the runner.
|
|
"""
|
|
resp = await client.post(_SHELL_PATH, json={"command": "echo hi"})
|
|
assert resp.status_code == 401, resp.text
|
|
assert runner_client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_allows_edit_collaborator_and_proxies(
|
|
client: httpx.AsyncClient,
|
|
runner_client: _RecordingRunnerClient,
|
|
) -> None:
|
|
"""An edit collaborator is allowed through and the command is proxied."""
|
|
resp = await client.post(
|
|
_SHELL_PATH,
|
|
json={"command": "echo hi"},
|
|
headers={"X-Forwarded-Email": "owner@example.com"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["stdout"] == "ok\n"
|
|
# The edit-level command reached the runner verbatim.
|
|
assert runner_client.posts == [(_SHELL_PATH, {"command": "echo hi"})]
|