mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 12:13:05 +00:00
A paused turn is found by the agent owner's id, so anyone holding one of the owner's agent keys could resume the owner's own chat with its saved wiki edit rights. A resume now counts as an API or widget caller when either the saved state or the resuming request is one, cuts the wiki tool to wiki_view unless the wiki allows outside edits, and gives the tool executor the same flags. A request that names an agent, by key or id, may only resume that agent's turn; otherwise the claim is released and the request refused. Public-link visitors run as themselves and reach only wikis they may edit, so the wiki switch no longer applies to them. Instead every wiki write in a public-link run waits for the visitor's approval, so the agent owner's prompt or sources can't steer an edit to the visitor's wiki unasked.
174 lines
7.5 KiB
Python
174 lines
7.5 KiB
Python
"""Prompt and wiki-tool access in the answer pipeline (live grant checks)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
|
|
import pytest
|
|
|
|
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
|
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
|
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
|
from docsgpt.storage.db.repositories.team_resource_grants import (
|
|
TeamResourceGrantsRepository,
|
|
)
|
|
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
|
|
|
OWNER = "alice"
|
|
|
|
|
|
@pytest.fixture
|
|
def use_conn(monkeypatch, pg_conn):
|
|
@contextmanager
|
|
def _yield():
|
|
yield pg_conn
|
|
|
|
monkeypatch.setattr("docsgpt.api.answer.services.stream_processor.db_readonly", _yield)
|
|
return pg_conn
|
|
|
|
|
|
def _share(conn, rtype, rid, member, level="viewer"):
|
|
team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
|
|
TeamMembersRepository(conn).add_member(str(team["id"]), member)
|
|
TeamResourceGrantsRepository(conn).grant(str(team["id"]), rtype, str(rid), OWNER, OWNER, access_level=level)
|
|
return team
|
|
|
|
|
|
def _processor(data, caller):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
return StreamProcessor(data, {"sub": caller})
|
|
|
|
|
|
class TestAgentlessPrompt:
|
|
def test_own_prompt_kept(self, use_conn):
|
|
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
|
|
proc = _processor({"question": "q", "prompt_id": pid}, OWNER)
|
|
proc._configure_agent()
|
|
assert proc.agent_config["prompt_id"] == pid
|
|
|
|
def test_shared_prompt_kept_then_falls_back_when_revoked(self, use_conn):
|
|
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
|
|
team = _share(use_conn, "prompt", pid, "bob")
|
|
proc = _processor({"question": "q", "prompt_id": pid}, "bob")
|
|
proc._configure_agent()
|
|
assert proc.agent_config["prompt_id"] == pid
|
|
TeamResourceGrantsRepository(use_conn).revoke(str(team["id"]), "prompt", pid)
|
|
proc = _processor({"question": "q", "prompt_id": pid}, "bob")
|
|
proc._configure_agent()
|
|
assert proc.agent_config["prompt_id"] == "default"
|
|
|
|
def test_strangers_prompt_falls_back_to_default(self, use_conn):
|
|
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "secret")["id"])
|
|
proc = _processor({"question": "q", "prompt_id": pid}, "eve")
|
|
proc._configure_agent()
|
|
assert proc.agent_config["prompt_id"] == "default"
|
|
|
|
def test_presets_pass_through_without_db(self):
|
|
proc = _processor({"question": "q", "prompt_id": "creative"}, "eve")
|
|
proc._configure_agent()
|
|
assert proc.agent_config["prompt_id"] == "creative"
|
|
|
|
|
|
class TestAgentPrompt:
|
|
def _run(self, monkeypatch, agent_owner, prompt_id, caller="carol"):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
monkeypatch.setattr(
|
|
StreamProcessor, "_get_data_from_api_key",
|
|
lambda self, key: {"prompt_id": prompt_id, "user": agent_owner, "_id": None},
|
|
)
|
|
proc = _processor({"question": "q", "api_key": "k"}, caller)
|
|
proc._configure_agent()
|
|
return proc.agent_config["prompt_id"]
|
|
|
|
def test_prompt_checked_against_agent_owner(self, use_conn, monkeypatch):
|
|
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
|
|
# Bob's agent uses Alice's prompt: allowed only while Bob has ``use``.
|
|
assert self._run(monkeypatch, "bob", pid) == "default"
|
|
_share(use_conn, "prompt", pid, "bob")
|
|
assert self._run(monkeypatch, "bob", pid) == pid
|
|
assert self._run(monkeypatch, OWNER, pid) == pid
|
|
|
|
def test_deleted_prompt_falls_back(self, use_conn, monkeypatch):
|
|
assert self._run(monkeypatch, OWNER, str(uuid.uuid4())) == "default"
|
|
|
|
|
|
class TestWikiConfigAccess:
|
|
def _wiki(self, conn):
|
|
return SourcesRepository(conn).create("W", user_id=OWNER, config={"kind": "wiki"})
|
|
|
|
def _cfg(self, conn, caller, sid):
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
proc = StreamProcessor.__new__(StreamProcessor)
|
|
proc.all_sources = [{"id": sid}]
|
|
proc.decoded_token = {"sub": caller}
|
|
return proc._build_wiki_config()
|
|
|
|
def test_owner_and_editor_get_tool_viewer_does_not(self, use_conn):
|
|
sid = str(self._wiki(use_conn)["id"])
|
|
assert self._cfg(use_conn, OWNER, sid)["source_owner_id"] == OWNER
|
|
_share(use_conn, "source", sid, "ed", "editor")
|
|
_share(use_conn, "source", sid, "vi", "viewer")
|
|
cfg = self._cfg(use_conn, "ed", sid)
|
|
assert cfg["source_owner_id"] == OWNER and cfg["user"] == "ed"
|
|
assert self._cfg(use_conn, "vi", sid) is None
|
|
assert self._cfg(use_conn, "eve", sid) is None
|
|
|
|
|
|
class TestWikiOutsideEdits:
|
|
"""API-key runs edit a wiki only when its owner allows; link visitors are asked."""
|
|
|
|
def _tools(self, conn, caller, sid, agent_config):
|
|
from docsgpt.agents.tools.wiki import WIKI_TOOL_ID, add_wiki_tool
|
|
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
|
|
|
proc = StreamProcessor.__new__(StreamProcessor)
|
|
proc.all_sources = [{"id": sid}]
|
|
proc.decoded_token = {"sub": caller}
|
|
proc.agent_config = agent_config
|
|
cfg = proc._build_wiki_config()
|
|
if cfg is None:
|
|
return None
|
|
tools = {}
|
|
add_wiki_tool(tools, cfg)
|
|
return {a["name"]: bool(a.get("require_approval")) for a in tools[WIKI_TOOL_ID]["actions"]}
|
|
|
|
def _wiki(self, conn, owner=OWNER):
|
|
return str(SourcesRepository(conn).create("W", user_id=owner, config={"kind": "wiki"})["id"])
|
|
|
|
def test_api_key_caller_reads_until_the_owner_allows_edits(self, use_conn):
|
|
# A widget or API run acts as the agent owner, who owns the wiki.
|
|
sid = self._wiki(use_conn)
|
|
assert self._tools(use_conn, OWNER, sid, {"external_api_caller": True}) == {"wiki_view": False}
|
|
SourcesRepository(use_conn).set_wiki_outside_edits(sid, OWNER, True)
|
|
tools = self._tools(use_conn, OWNER, sid, {"external_api_caller": True})
|
|
assert tools["wiki_create"] is False
|
|
|
|
def test_owner_and_team_editor_keep_every_action(self, use_conn):
|
|
sid = self._wiki(use_conn)
|
|
_share(use_conn, "source", sid, "ed", "editor")
|
|
assert self._tools(use_conn, OWNER, sid, {})["wiki_create"] is False
|
|
assert self._tools(use_conn, "ed", sid, {})["wiki_create"] is False
|
|
|
|
def test_link_visitor_edits_their_own_wiki_after_approving(self, use_conn):
|
|
# Visitor owns a wiki that the agent (someone else's) also uses.
|
|
sid = self._wiki(use_conn, owner="visitor")
|
|
tools = self._tools(use_conn, "visitor", sid, {"public_link_caller": True})
|
|
assert tools["wiki_view"] is False
|
|
assert tools["wiki_create"] is True and tools["wiki_delete"] is True
|
|
|
|
def test_link_visitor_who_edits_the_wiki_through_a_team(self, use_conn):
|
|
sid = self._wiki(use_conn)
|
|
_share(use_conn, "source", sid, "visitor", "editor")
|
|
tools = self._tools(use_conn, "visitor", sid, {"public_link_caller": True})
|
|
assert tools["wiki_create"] is True
|
|
|
|
def test_link_visitor_without_edit_rights_gets_no_wiki_tool(self, use_conn):
|
|
sid = self._wiki(use_conn)
|
|
assert self._tools(use_conn, "visitor", sid, {"public_link_caller": True}) is None
|
|
_share(use_conn, "source", sid, "visitor", "viewer")
|
|
assert self._tools(use_conn, "visitor", sid, {"public_link_caller": True}) is None
|