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:
Ray
2026-08-12 21:10:56 +08:00
parent 18171d6358
commit 9f67fdd17b
7 changed files with 168 additions and 25 deletions
+11 -5
View File
@@ -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
View File
@@ -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}"],
+6 -2
View File
@@ -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:
+4 -1
View File
@@ -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:
+3 -3
View File
@@ -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).
+80
View File
@@ -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"):
+20
View File
@@ -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