mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-10-03 08:08:20 +08:00
fix: enforce doc_id at the tool layer in the framework config helpers
openai_agent_config / anthropic_runner_config / claude_agent_config accepted doc_id but built unscoped tools, so the parameter that is a structural allowlist on chat_completions() was prompt-only advice here — the agent could read every document in the store regardless. - as_openai_tools / as_anthropic_tools / as_claude_mcp take a doc_id tail parameter and thread it to the existing _allowed_ids channel; the config helpers pass it through in local mode - cloud config helpers keep prompt-level targeting (tool scoping is server-side there, documented); explicit as_*(doc_id=...) raises on cloud instead of silently dropping the allowlist — including the hosted branch, which returned before _tool_specs' existing guard - _require_local_scope consolidates the cloud rejection that was inlined in _tool_specs - doc_id=[] is an empty allowlist, not "unscoped": dropped the `or None` at the three local chat surfaces
This commit is contained in:
@@ -1380,18 +1380,24 @@ def _build_cloud_agent_tools(client, include_management: bool) -> list[Callable[
|
||||
return [_make_bridge_function(bridge, meta) for meta in tools_meta]
|
||||
|
||||
|
||||
def _require_local_scope(client, doc_ids) -> None:
|
||||
"""The allowlist is enforced in-process; cloud lookups run server-side,
|
||||
so accepting doc_ids there would be advisory-only — refuse loudly."""
|
||||
if doc_ids is not None and getattr(client, "api_key", None):
|
||||
raise PageIndexAPIError(
|
||||
"doc_ids scoping applies to local tools only — cloud calls "
|
||||
"are scoped server-side."
|
||||
)
|
||||
|
||||
|
||||
def _tool_specs(client, include_management: bool = False, doc_ids=None,
|
||||
) -> "list[tuple[str, str, dict, Callable[[dict], tuple[str, bool]]]]":
|
||||
"""(name, description, schema, invoke) per tool, for adapters that take
|
||||
the wire schema verbatim. ``invoke`` returns (envelope_text, is_error).
|
||||
Schemas are copies (frameworks keep the dict by reference). ``doc_ids``
|
||||
is the local chat scope; cloud scoping is server-side."""
|
||||
_require_local_scope(client, doc_ids)
|
||||
if getattr(client, "api_key", None):
|
||||
if doc_ids is not None:
|
||||
raise PageIndexAPIError(
|
||||
"doc_ids scoping applies to local tools only — cloud calls "
|
||||
"are scoped server-side."
|
||||
)
|
||||
bridge = _cloud_bridge(client)
|
||||
tools_meta = bridge.list_tools()
|
||||
if not include_management:
|
||||
|
||||
+44
-14
@@ -606,7 +606,8 @@ class PageIndexClient:
|
||||
return build_agent_tools(self, include_management)
|
||||
|
||||
def as_openai_tools(self, include_management: bool = False,
|
||||
hosted: bool = False) -> list:
|
||||
hosted: bool = False,
|
||||
doc_id: Optional[Union[str, list[str]]] = None) -> list:
|
||||
"""
|
||||
Tools for the OpenAI Agents SDK — pass to ``Agent(tools=...)``
|
||||
(or ``openai_agent_config()`` for all the Agent slots in one
|
||||
@@ -636,9 +637,20 @@ class PageIndexClient:
|
||||
read-only endpoint (``/mcp?tools=read``) instead.
|
||||
hosted (bool): Cloud only — hand the MCP connection to OpenAI
|
||||
for server-side tool execution (OpenAI models only).
|
||||
doc_id: Local only — restrict the tools to this document ID
|
||||
(or list of IDs), enforced at the tool layer: out-of-scope
|
||||
lookups return NOT_FOUND. Raises on cloud, where scoping
|
||||
is server-side.
|
||||
"""
|
||||
from .integrations.openai_agents import build_openai_tools
|
||||
return build_openai_tools(self, include_management, hosted)
|
||||
return build_openai_tools(self, include_management, hosted,
|
||||
doc_ids=doc_id)
|
||||
|
||||
def _local_doc_scope(self, doc_id):
|
||||
"""doc_id for the tool layer: passed through locally (structural
|
||||
allowlist), dropped on cloud where scoping is server-side and the
|
||||
config helpers keep prompt-level targeting."""
|
||||
return None if getattr(self, "api_key", None) else doc_id
|
||||
|
||||
def openai_agent_config(
|
||||
self,
|
||||
@@ -661,7 +673,9 @@ class PageIndexClient:
|
||||
|
||||
Args:
|
||||
doc_id: Document ID or list of IDs to target, as in
|
||||
``agent_instructions``.
|
||||
``agent_instructions``. Local: also enforced at the tool
|
||||
layer, not just prompted. Cloud: prompt-level targeting
|
||||
(tool scoping is server-side).
|
||||
include_management (bool): Also expose tools that modify the
|
||||
library.
|
||||
model: Backend model name; overrides the local default.
|
||||
@@ -669,7 +683,8 @@ class PageIndexClient:
|
||||
config: dict[str, Any] = {
|
||||
"name": "PageIndex",
|
||||
"instructions": self.agent_instructions(doc_id=doc_id),
|
||||
"tools": self.as_openai_tools(include_management),
|
||||
"tools": self.as_openai_tools(include_management,
|
||||
doc_id=self._local_doc_scope(doc_id)),
|
||||
}
|
||||
model = model or getattr(self, "retrieve_model", None)
|
||||
if model:
|
||||
@@ -677,7 +692,9 @@ class PageIndexClient:
|
||||
return config
|
||||
|
||||
def as_anthropic_tools(self, include_management: bool = False,
|
||||
asynchronous: bool = False) -> list:
|
||||
asynchronous: bool = False,
|
||||
doc_id: Optional[Union[str, list[str]]] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Runnable tools for the Anthropic SDK's tool runner — pass to
|
||||
``client.beta.messages.tool_runner(tools=...)`` (or
|
||||
@@ -712,9 +729,14 @@ class PageIndexClient:
|
||||
``AsyncAnthropic`` (each tool call runs in a worker
|
||||
thread, keeping blocking I/O off your event loop). The
|
||||
sync and async runners each accept only their own flavor.
|
||||
doc_id: Local only — restrict the tools to this document ID
|
||||
(or list of IDs), enforced at the tool layer: out-of-scope
|
||||
lookups return NOT_FOUND. Raises on cloud, where scoping
|
||||
is server-side.
|
||||
"""
|
||||
from .integrations.anthropic_sdk import build_anthropic_tools
|
||||
return build_anthropic_tools(self, include_management, asynchronous)
|
||||
return build_anthropic_tools(self, include_management, asynchronous,
|
||||
doc_ids=doc_id)
|
||||
|
||||
def anthropic_runner_config(
|
||||
self,
|
||||
@@ -745,7 +767,9 @@ class PageIndexClient:
|
||||
model: Backend model name (also resolves the ``max_tokens``
|
||||
default).
|
||||
doc_id: Document ID or list of IDs to target, as in
|
||||
``agent_instructions``.
|
||||
``agent_instructions``. Local: also enforced at the tool
|
||||
layer, not just prompted. Cloud: prompt-level targeting
|
||||
(tool scoping is server-side).
|
||||
include_management (bool): Also expose tools that modify the
|
||||
library.
|
||||
asynchronous (bool): Build async runnables for
|
||||
@@ -760,12 +784,13 @@ class PageIndexClient:
|
||||
"max_tokens": (max_tokens if max_tokens is not None
|
||||
else _default_max_tokens(model)),
|
||||
"system": self.agent_instructions(doc_id=doc_id),
|
||||
"tools": self.as_anthropic_tools(include_management,
|
||||
asynchronous),
|
||||
"tools": self.as_anthropic_tools(include_management, asynchronous,
|
||||
doc_id=self._local_doc_scope(doc_id)),
|
||||
"max_iterations": max_turns if max_turns is not None else 10,
|
||||
}
|
||||
|
||||
def as_claude_mcp(self, include_management: bool = False):
|
||||
def as_claude_mcp(self, include_management: bool = False,
|
||||
doc_id: Optional[Union[str, list[str]]] = None):
|
||||
"""
|
||||
``mcp_servers`` entry for the Claude Agent SDK.
|
||||
|
||||
@@ -776,7 +801,9 @@ class PageIndexClient:
|
||||
``True`` connects to the full tool set. Local: returns an
|
||||
in-process SDK MCP server exposing the agent tools, gated the
|
||||
same way at registration (requires ``claude-agent-sdk``;
|
||||
``pip install 'pageindex[claude]'``).
|
||||
``pip install 'pageindex[claude]'``). ``doc_id`` (local only)
|
||||
restricts those tools to that document ID (or list), enforced at
|
||||
the tool layer; it raises on cloud, where scoping is server-side.
|
||||
|
||||
Cloud hosts that surface MCP server instructions receive the same
|
||||
guidance ``agent_instructions()`` returns natively — passing both
|
||||
@@ -795,7 +822,7 @@ class PageIndexClient:
|
||||
)
|
||||
"""
|
||||
from .integrations.claude_agent_sdk import build_claude_mcp
|
||||
return build_claude_mcp(self, include_management)
|
||||
return build_claude_mcp(self, include_management, doc_ids=doc_id)
|
||||
|
||||
def claude_agent_config(
|
||||
self,
|
||||
@@ -817,14 +844,17 @@ class PageIndexClient:
|
||||
|
||||
Args:
|
||||
doc_id: Document ID or list of IDs to target, as in
|
||||
``agent_instructions``.
|
||||
``agent_instructions``. Local: also enforced at the tool
|
||||
layer, not just prompted. Cloud: prompt-level targeting
|
||||
(tool scoping is server-side).
|
||||
include_management (bool): Also allow tools that modify the
|
||||
library.
|
||||
server_name (str): Key the server is registered under.
|
||||
"""
|
||||
return {
|
||||
"system_prompt": self.agent_instructions(doc_id=doc_id),
|
||||
"mcp_servers": {server_name: self.as_claude_mcp(include_management)},
|
||||
"mcp_servers": {server_name: self.as_claude_mcp(
|
||||
include_management, doc_id=self._local_doc_scope(doc_id))},
|
||||
# Pre-approval only — the server itself is already gated (the
|
||||
# read-only endpoint on cloud, the registered set locally).
|
||||
"allowed_tools": [f"mcp__{server_name}"],
|
||||
|
||||
@@ -14,7 +14,11 @@ from .._version import sdk_version
|
||||
from ..errors import PageIndexAPIError
|
||||
|
||||
|
||||
def build_claude_mcp(client, include_management: bool = False):
|
||||
def build_claude_mcp(client, include_management: bool = False, doc_ids=None):
|
||||
from ..agent_tools import _require_local_scope
|
||||
# The cloud branch returns a URL config — reject cloud doc_ids so they
|
||||
# are never silently dropped.
|
||||
_require_local_scope(client, doc_ids)
|
||||
if getattr(client, "api_key", None):
|
||||
# include_management picks the endpoint — the URL itself is the
|
||||
# gate (?tools=read serves only readOnlyHint-annotated tools).
|
||||
@@ -38,7 +42,7 @@ def build_claude_mcp(client, include_management: bool = False):
|
||||
def make_handler(name: str):
|
||||
async def handler(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
text, is_error = await asyncio.to_thread(
|
||||
call_tool, client, name, arguments or {}
|
||||
call_tool, client, name, arguments or {}, doc_ids
|
||||
)
|
||||
result: dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
||||
if is_error:
|
||||
|
||||
@@ -27,6 +27,10 @@ def build_openai_tools(client, include_management: bool = False,
|
||||
"as_openai_tools requires the OpenAI Agents SDK — "
|
||||
"pip install openai-agents (or pip install 'pageindex[openai]')."
|
||||
) from exc
|
||||
from ..agent_tools import _require_local_scope, _tool_specs
|
||||
# The hosted branch returns before _tool_specs — reject cloud doc_ids
|
||||
# here so they are never silently dropped.
|
||||
_require_local_scope(client, doc_ids)
|
||||
if getattr(client, "api_key", None) and hosted:
|
||||
# include_management picks the endpoint — the URL itself is the
|
||||
# gate (?tools=read serves only readOnlyHint-annotated tools), so
|
||||
@@ -39,7 +43,6 @@ def build_openai_tools(client, include_management: bool = False,
|
||||
"headers": {"Authorization": f"Bearer {client.api_key}"},
|
||||
"require_approval": "never",
|
||||
})]
|
||||
from ..agent_tools import _tool_specs
|
||||
|
||||
def wrap(name, description, schema, invoke):
|
||||
async def on_invoke_tool(ctx: Any, args_json: str) -> str:
|
||||
|
||||
@@ -349,7 +349,7 @@ def run_chat_completions(client, messages, stream: bool = False,
|
||||
model_name = model or client.retrieve_model
|
||||
managed = _managed_instructions(system_texts)
|
||||
agent = _openai_agent(client, "chat", model_name, managed,
|
||||
temperature, None, doc_ids=doc_id or None)
|
||||
temperature, None, doc_ids=doc_id)
|
||||
run_kwargs = _run_kwargs(max_turns,
|
||||
_conversation_group_id(model_name, managed, items))
|
||||
from agents import Runner
|
||||
@@ -450,7 +450,7 @@ def run_responses(client, input, model: Optional[str] = None,
|
||||
model_name = model or client.retrieve_model
|
||||
managed = _managed_instructions(extra)
|
||||
agent = _openai_agent(client, "responses", model_name, managed,
|
||||
temperature, top_p, doc_ids=doc_id or None)
|
||||
temperature, top_p, doc_ids=doc_id)
|
||||
run_kwargs = _run_kwargs(max_turns,
|
||||
_conversation_group_id(model_name, managed, items))
|
||||
recorded: dict = {}
|
||||
@@ -691,7 +691,7 @@ def run_messages(client, messages, model: str,
|
||||
else _default_max_tokens(model)),
|
||||
messages=prepared,
|
||||
model=model,
|
||||
tools=build_anthropic_tools(client, doc_ids=doc_id or None),
|
||||
tools=build_anthropic_tools(client, doc_ids=doc_id),
|
||||
system=_anthropic_system(system, block),
|
||||
stream=stream,
|
||||
# Bounded like the OpenAI surfaces (their framework default is 10).
|
||||
|
||||
@@ -471,6 +471,10 @@ def test_call_tool_doc_scope_limits_every_lookup(client, store_path):
|
||||
{"doc_name": "report.pdf"}, doc_ids="pi-a")
|
||||
assert not is_error
|
||||
|
||||
# An empty allowlist scopes to nothing — it must not read as "unscoped".
|
||||
text, is_error = call_tool(client, "browse_documents", {}, doc_ids=[])
|
||||
assert not is_error and json.loads(text)["documents"] == []
|
||||
|
||||
|
||||
def test_call_tool_scope_channel_not_injectable(client, store_path):
|
||||
"""Model arguments cannot smuggle an allowlist: underscore keys are
|
||||
@@ -740,6 +744,82 @@ def test_anthropic_runner_config_cloud(cloud_with_fake_bridge):
|
||||
"get_document"]
|
||||
|
||||
|
||||
# ── config helpers: doc_id is structural in the tools, not just prompted ──
|
||||
|
||||
def test_openai_agent_config_doc_scope_enforced_in_tools(client, store_path):
|
||||
pytest.importorskip("agents")
|
||||
seed_doc(store_path, "pi-a", "report.pdf")
|
||||
seed_doc(store_path, "pi-b", "payroll.pdf",
|
||||
created_at="2026-08-02T10:00:00.123000")
|
||||
tools = {tool.name: tool
|
||||
for tool in client.openai_agent_config(doc_id="pi-a")["tools"]}
|
||||
out = asyncio.run(tools["get_page_content"].on_invoke_tool(
|
||||
None, json.dumps({"doc_name": "payroll.pdf", "pages": "1"})))
|
||||
assert json.loads(out)["errorCode"] == "NOT_FOUND"
|
||||
out = asyncio.run(tools["browse_documents"].on_invoke_tool(None, "{}"))
|
||||
assert [doc["name"]
|
||||
for doc in json.loads(out)["documents"]] == ["report.pdf"]
|
||||
|
||||
|
||||
def test_anthropic_runner_config_doc_scope_enforced_in_tools(client,
|
||||
store_path):
|
||||
pytest.importorskip("anthropic")
|
||||
from anthropic.lib.tools import ToolError
|
||||
seed_doc(store_path, "pi-a", "report.pdf")
|
||||
seed_doc(store_path, "pi-b", "payroll.pdf",
|
||||
created_at="2026-08-02T10:00:00.123000")
|
||||
config = client.anthropic_runner_config(model="claude-sonnet-4-5",
|
||||
doc_id="pi-a")
|
||||
tools = {tool.name: tool for tool in config["tools"]}
|
||||
with pytest.raises(ToolError, match="NOT_FOUND"):
|
||||
tools["get_page_content"].call({"doc_name": "payroll.pdf",
|
||||
"pages": "1"})
|
||||
browse = json.loads(tools["browse_documents"].call({}))
|
||||
assert [doc["name"] for doc in browse["documents"]] == ["report.pdf"]
|
||||
|
||||
|
||||
def test_claude_agent_config_doc_scope_enforced_in_tools(client, store_path):
|
||||
pytest.importorskip("claude_agent_sdk")
|
||||
from mcp.types import CallToolRequest, CallToolRequestParams
|
||||
seed_doc(store_path, "pi-a", "report.pdf")
|
||||
seed_doc(store_path, "pi-b", "payroll.pdf",
|
||||
created_at="2026-08-02T10:00:00.123000")
|
||||
config = client.claude_agent_config(doc_id="pi-a")
|
||||
server = config["mcp_servers"]["pageindex"]
|
||||
handler = server["instance"].request_handlers[CallToolRequest]
|
||||
result = asyncio.run(handler(CallToolRequest(
|
||||
method="tools/call",
|
||||
params=CallToolRequestParams(
|
||||
name="get_page_content",
|
||||
arguments={"doc_name": "payroll.pdf", "pages": "1"}))))
|
||||
payload = json.loads(result.root.content[0].text)
|
||||
assert payload["errorCode"] == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_doc_scope_rejected_on_cloud_openai():
|
||||
pytest.importorskip("agents")
|
||||
cloud = PageIndexCloudClient(api_key="pi-test-key")
|
||||
with pytest.raises(PageIndexAPIError, match="server-side"):
|
||||
cloud.as_openai_tools(doc_id="pi-a")
|
||||
# The hosted branch returns before _tool_specs — it must reject too,
|
||||
# not silently drop the allowlist.
|
||||
with pytest.raises(PageIndexAPIError, match="server-side"):
|
||||
cloud.as_openai_tools(hosted=True, doc_id="pi-a")
|
||||
|
||||
|
||||
def test_doc_scope_rejected_on_cloud_anthropic():
|
||||
pytest.importorskip("anthropic")
|
||||
cloud = PageIndexCloudClient(api_key="pi-test-key")
|
||||
with pytest.raises(PageIndexAPIError, match="server-side"):
|
||||
cloud.as_anthropic_tools(doc_id="pi-a")
|
||||
|
||||
|
||||
def test_doc_scope_rejected_on_cloud_claude():
|
||||
cloud = PageIndexCloudClient(api_key="pi-test-key")
|
||||
with pytest.raises(PageIndexAPIError, match="server-side"):
|
||||
cloud.as_claude_mcp(doc_id="pi-a")
|
||||
|
||||
|
||||
def test_as_anthropic_tools_missing_dependency(client, monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "anthropic", None)
|
||||
with pytest.raises(PageIndexAPIError, match="anthropic"):
|
||||
|
||||
@@ -629,6 +629,11 @@ def test_conversation_group_id_stable_per_conversation():
|
||||
"m", "sys", [{"role": "user", "content": "other"}])
|
||||
assert key != local_chat._conversation_group_id("m2", "sys", turn1)
|
||||
assert key != local_chat._conversation_group_id("m", "sys2", turn1)
|
||||
|
||||
|
||||
@needs_agents
|
||||
def test_run_kwargs_sets_conversation_group_id():
|
||||
key = "pageindex-test"
|
||||
assert (local_chat._run_kwargs(None, key)["run_config"].group_id == key)
|
||||
|
||||
|
||||
@@ -664,6 +669,21 @@ def test_doc_id_scopes_tools_to_targeted_documents(client, store_path,
|
||||
assert [doc["name"] for doc in browse["documents"]] == ["report.pdf"]
|
||||
|
||||
|
||||
@needs_agents
|
||||
def test_empty_doc_id_is_an_empty_allowlist(client, store_path, fake_model):
|
||||
"""doc_id=[] scopes the agent to nothing; `or None` used to wash it
|
||||
into unscoped full-library access."""
|
||||
seed_doc(store_path, "pi-a", "report.pdf")
|
||||
fake = fake_model([
|
||||
[_call_item("browse_documents", {})],
|
||||
[_msg_item("done")],
|
||||
])
|
||||
client.chat_completions("q", doc_id=[])
|
||||
outputs = [item["output"] for item in fake.inputs[1]
|
||||
if item.get("type") == "function_call_output"]
|
||||
assert json.loads(outputs[-1])["documents"] == []
|
||||
|
||||
|
||||
@needs_agents
|
||||
def test_openai_model_resolves_provider_prefixes():
|
||||
"""retrieve_model arrives normalized (litellm/<provider>/<model>); the
|
||||
|
||||
Reference in New Issue
Block a user