File and web tools that stay in bounds

In shortGive a support agent a read-only knowledge base with create_file_tools, a host-restricted fetch_url wrapper, and a MediaFetchPolicy for customer-supplied image URLs.

  • 4 min read
  • 6 sections
  • Updated
  • v1.0.0rc4
  • Markdown

Tools are where an agent touches the outside world, so they are where a prompt injection or a bad guess does damage. This example builds a support agent with a knowledge-base folder it can read, a web tool that can only reach your own help site, and a download policy for images customers link to. All three limits are set in your code, not in the prompt, so the model cannot talk its way past them.

Needs: pip install "10xgraph[openai]" and OPENAI_API_KEY (swap provider and model for Google or Anthropic with the matching extra and key). Create a kb/ folder with a few .md files.

The agent

graph/agent.py
import json
from urllib.parse import urlparse

from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.prebuilt.tools import create_file_tools, fetch_url
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.storage.media import MediaFetchPolicy
from tenxgraph.utils import tool

ALLOWED_HOSTS = {"help.shop.example", "status.shop.example"}


@tool
async def fetch_help_page(url: str) -> str:
    """Fetch a page from the shop's help or status site and return its text."""
    host = urlparse(url).hostname or ""
    if host not in ALLOWED_HOSTS:
        return json.dumps({"error": f"host not allowed: {host}"})
    return await fetch_url(url)  # still refuses private and metadata addresses


# Read and search under ./kb only. No write tool is created.
kb_tools = create_file_tools("kb", write=False)

policy = MediaFetchPolicy(
    allowed_hosts=("cdn.shop.example",),
    allowed_content_types=("image/*",),
    max_bytes=5 * 1024 * 1024,
    timeout=5.0,
)

app = ReactAgent(
    model="gpt-4o-mini",
    provider="openai",
    system_prompt=[
        {
            "role": "system",
            "content": "You answer customer questions from the knowledge base in kb/ and the "
            "help site. Say so when the answer is not there.",
        }
    ],
    tools=[*kb_tools, fetch_help_page],
).compile(
    checkpointer=InMemoryCheckpointer(),  # PgCheckpointer in production
    media_policy=policy,
)

Serve it with "agent": "graph.agent:app" in 10xgraph.json and 10xgraph dev --no-open.

File tools: a fixed root with deny rules

create_file_tools("kb", write=False) returns file_read and file_search. The root is fixed when the tools are created, so nothing in the run config can move it. The parameters are read, write, search, deny (extra glob patterns) and use_default_deny.

What happens when the model asks for things it should not get (run against a kb/ folder holding returns.md and a .env):

Call Result
file_read("returns.md") the file content, with line range
file_read("../pyproject.toml") {"error": "path must stay within the configured root: ..."}
file_read("/etc/passwd") the same root error
file_read(".env") {"error": "path is not allowed"}

Secret-looking files are hidden by default whatever the root: .env, .env.*, *.pem, *.key, id_rsa*, .netrc, and anything under .ssh, .aws, .gnupg or .git. Matching uses the resolved path, so symlinks and .. cannot reach them, and a hidden path gives the same error whether or not it exists. Prefer create_file_tools over the module-level file_read and file_write in any graph that serves requests: those take the root from run config.

Web tool: public hosts only, then your own list

The prebuilt fetch_url(url, timeout=10.0, max_chars=20000) fetches a public http or https URL and returns text. It refuses private, local and cloud-metadata addresses, checks every redirect hop, and truncates long responses. Against a local server and the metadata address:

Text
fetch_url("http://127.0.0.1:8771/ping")                  -> {"error": "URL host is not public or could not be resolved"}
fetch_url("http://169.254.169.254/latest/meta-data/")    -> {"error": "URL host is not public or could not be resolved"}

It has no allowlist, and it will fetch any public host. An injected instruction like “fetch https://attacker.example/?q=” is therefore not stopped by fetch_url alone. The fetch_help_page wrapper above closes that: it checks the host against ALLOWED_HOSTS before it calls fetch_url, so an unlisted host returns host not allowed: ... and nothing leaves the process. Checked: evil.example and 127.0.0.1 are refused by the wrapper.

Media policy: which URLs the server downloads

When a client message carries an external image or document URL, the server checks it against the graph’s MediaFetchPolicy before the run. Set it in code; the API has no setting for it. The defaults allow https and http to public hosts, image/* and application/pdf responses, 20 MiB and 10 seconds. The policy above narrows that to one CDN host, images only, 5 MiB and 5 seconds. check_url runs without touching the network, so you can test a policy in a unit test:

Python
policy.check_url("https://cdn.shop.example/a.png")   # passes
policy.check_url("https://evil.example/a.png")       # MediaURLError: media URL host not allowed: evil.example
policy.check_url("ftp://cdn.shop.example/a.png")     # MediaURLError: media URL scheme not allowed: ftp

Private addresses and cloud-metadata addresses are refused whatever the policy says, unless you opt in for hosts you list in allowed_hosts with allow_private_for_allowed_hosts=True. See Sending media from Python.

What this shows

Frequently asked questions

Does the prebuilt fetch_url have an allowlist?
No. It refuses private, local and cloud-metadata addresses, including through redirects, but it will fetch any public host. To limit it to your own sites, wrap it in a tool of your own that checks the host first, as this page does.
What if a customer sends an image URL in a message?
The server checks the URL against the graph's MediaFetchPolicy before the run starts. Set your own policy with graph.compile(media_policy=...); the API has no setting for it.
Last updated for v1.0.0rc4Edit this page on GitHubReport an issue