

How to develop agents that can interact with external data safely – lessons from the Australian government‑site incident.
⚠️ Disclaimer – The code below is intended for defensive, educational use only. It shows how to harness OpenAI’s APIs while applying strict security controls (input validation, allow‑listing, sandboxing, rate‑limiting, etc.). Do not use these examples to probe, scrape, or compromise any system without explicit permission.
<a name="step-1-prerequisites"></a>
| Item | Why you need it | Version tested |
|---|---|---|
| OpenAI account | To obtain an API key (or use Azure OpenAI) | – |
| API key (with restricted permissions if possible) | Authenticates requests to OpenAI endpoints | – |
| Python ≥ 3.9 | Runtime for the Python example | 3.11 |
| Node.js ≥ 18 (LTS) | Runtime for the JS/TS example | 20.11 |
| Package managers | pip for Python, npm/yarn/pnpm for JS | – |
| Git (optional) | To clone the sample repo | – |
| IDE / editor | VS Code, PyCharm, WebStorm, etc. | – |
| Basic HTTP client knowledge | To understand the sample calls that fetch external data | – |
Security tip – Create a separate OpenAI API key for each environment (dev, staging, prod) and restrict it to the specific model(s) you need (e.g.,
gpt-4-turbo-preview).
<a name="step-2-installation-and-setup"></a>
# 1️⃣ Clone the repo (optional)
git clone https://github.com/icarax/openai-agent-demo.git
cd openai-agent-demo/python
# 2️⃣ Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3️⃣ Install dependencies
pip install --upgrade pip
pip install openai python-dotenv tenacity
# 1️⃣ Clone the repo (optional)
git clone https://github.com/icarax/openai-agent-demo.git
cd openai-agent-demo/js
# 2️⃣ Install Node deps
npm ci # or `yarn install` / `pnpm install`
# 3️⃣ Install core packages
npm install openai dotenv zod # zod for runtime validation
# If you prefer TypeScript:
npm install -D typescript @types/node ts-node
npx tsc --init # creates a basic tsconfig.json
<a name="step-3-basic-implementation"></a>
Below are complete, copy‑paste‑ready scripts that:
The function
fetch_allowed_urlpretends to be a thin wrapper around an internal proxy that only permits requests to a pre‑approved list (e.g.,*.gov.au). In a real system you would replace this with your own API gateway, Cloudflare Workers, or a VPC‑locked Lambda.
# file: agent.py
"""
Secure OpenAI agent demo (Python).
- Uses function calling to ask the model to retrieve data from a whitelisted URL.
- Implements exponential back‑off retries via `tenacity`.
- Loads configuration from a .env file (see Step 4).
"""
import os
import json
import logging
from typing import List, Dict, Any
import openai
from dotenv import load_dotenv
from tenacity import retry, wait_exponential, stop_after_attempt, retry_if_exception_type
# ----------------------------------------------------------------------
# Configuration & logging
# ----------------------------------------------------------------------
load_dotenv() # reads .env into os.environ
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY not set in environment")
openai.api_key = OPENAI_API_KEY
openai.api_base = os.getenv("OPENAI_API_BASE", None) # e.g., for Azure OpenAI
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
log = logging.getLogger("openai_agent")
# ----------------------------------------------------------------------
# Whitelist – ONLY these hosts may be contacted by the agent
# ----------------------------------------------------------------------
ALLOWED_HOSTS = {
"data.gov.au",
"www.abs.gov.au",
# add any other domains you explicitly trust
}
def _is_host_allowed(url: str) -> bool:
"""Very simple host‑allowlist check."""
try:
from urllib.parse import urlparse
host = urlparse(url).hostname or ""
return host.endswith(tuple(ALLOWED_HOSTS))
except Exception:
return False
# ----------------------------------------------------------------------
# Tool definition – the model can call this function to fetch a URL
# ----------------------------------------------------------------------
def fetch_allowed_url(url: str) -> str:
"""
Retrieve the raw text content of `url` **only** if it passes the allowlist.
In production replace this with a secured internal proxy that adds
timeout, user‑agent, and further sanitisation.
"""
if not _is_host_allowed(url):
raise ValueError(f"URL not allowed: {url}")
import requests
try:
resp = requests.get(url, timeout=10, headers={"User-Agent": "OpenAI-Agent/1.0"})
resp.raise_for_status()
# Truncate to avoid overwhelming the model with huge payloads
content = resp.text[:8000]
log.info(f"Fetched {len(content)} chars from {url}")
return content
except requests.RequestException as exc:
log.error(f"HTTP error while fetching {url}: {exc}")
raise
# ----------------------------------------------------------------------
# OpenAI function schema (as defined by the Chat Completions API)
# ----------------------------------------------------------------------
FETCH_URL_FUNCTION = {
"name": "fetch_allowed_url",
"description": "Fetch the contents of a whitelisted URL. Use only for trusted government or public data sources.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch (must be on the allowlist).",
}
},
"required": ["url"],
"additionalProperties": False,
},
}
# ----------------------------------------------------------------------
# Core agent logic – retry on transient errors (rate limits, network glitches)
# ----------------------------------------------------------------------
@retry(
reraise=True,
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=20),
retry=retry_if_exception_type((openai.error.RateLimitError, openai.error.APIConnectionError)),
)
def run_agent(user_query: str) -> str:
"""
Send a query to the model, allowing it to call `fetch_allowed_url` if needed.
Returns the final assistant message.
"""
messages: List[Dict[str, Any]] = [
{"role": "system", "content": (
"You are a helpful research assistant. "
"When you need up‑to‑date factual data, you MUST call the `fetch_allowed_url` function "
"with a URL from the allowed list. Never fabricate URLs or claim to have visited a site "
"without using the tool."
)},
{"role": "user", "content": user_query},
]
response = openai.ChatCompletion.create(
model=os.getenv("OPENAI_MODEL", "gpt-4-turbo-preview"),
messages=messages,
tools=[{"type": "function", "function": FETCH_URL_FUNCTION}],
tool_choice="auto", # let the model decide when to use the tool
temperature=0.2,
max_tokens=1500,
)
# ------------------------------------------------------------------
# Handle possible tool calls
# ------------------------------------------------------------------
if response.choices[0].message.get("tool_calls"):
tool_calls = response.choices[0].message["tool_calls"]
# Append the assistant's message with tool_calls so the model sees it
messages.append(response.choices[0].message)
for tool_call in tool_calls:
if tool_call["function"]["name"] == "fetch_allowed_url":
args = json.loads(tool_call["function"]["arguments"])
url = args["url"]
try:
result = fetch_allowed_url(url)
except Exception as exc:
# Return a friendly error to the model so it can adjust
result = f"Error fetching URL: {exc}"
# Append the tool result
messages.append(
{
"role": "tool",
"tool_call_id": tool_call["id"],
"name": "fetch_allowed_url",
"content": result,
}
)
# Get a final response after the tool output
final_response = openai.ChatCompletion.create(
model=os.getenv("OPENAI_MODEL", "gpt-4-turbo-preview"),
messages=messages,
temperature=0.2,
max_tokens=1500,
)
return final_response.choices[0].message["content"]
else:
# No tool used – just return the model's answer
return response.choices[0].message["content"]
# ----------------------------------------------------------------------
# Simple CLI entrypoint
# ----------------------------------------------------------------------
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python agent.py \"<your question>\"")
sys.exit(1)
question = " ".join(sys.argv[1:])
try:
answer = run_agent(question)
print("\n=== Assistant Answer ===\n")
print(answer)
except Exception as e:
log.exception("Agent failed")
print(f"\n❌ Error: {e}")
// file: agent.ts
/*
Secure OpenAI agent demo (TypeScript).
- Uses the OpenAI Chat Completions API with function calling.
- Validates URLs against an allowlist before performing an HTTP request.
- Implements retry with exponential back‑off via a tiny helper.
- Requires Node >=18 and `dotenv` for config.
*/
import { Configuration, OpenAIApi } from "openai";
import * as dotenv from "dotenv";
import * as z from "zod";
import axios from "axios";
import { log } from "console";
// ----------------------------------------------------------------------
// Load environment variables
// ----------------------------------------------------------------------
dotenv.config();
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (!OPENAI_API_KEY) {
throw new Error("Missing OPENAI_API_KEY in environment");
}
const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4-turbo-preview";
const configuration = new Configuration({
apiKey: OPENAI_API_KEY,
// basePath: process.env.OPENAI_API_BASE, // Uncomment for Azure/OpenAI proxies
});
const openai = new OpenAIApi(configuration);
// ----------------------------------------------------------------------
// Allowlist – only these hosts may be fetched
// ----------------------------------------------------------------------
const ALLOWED_HOSTS = new Set([
"data.gov.au",
"www.abs.gov.au",
// add more as needed
]);
function isHostAllowed(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname && [...ALLOWED_HOSTS].some((h) => hostname.endsWith(h));
} catch {
return false;
}
}
// ----------------------------------------------------------------------
// Tool: fetch a whitelisted URL (returns plain text, truncated)
// ----------------------------------------------------------------------
const fetchUrlSchema = z.object({
url: z.string().url().refine(isHostAllowed, {
message: "URL is not on the allowlist",
}),
});
type FetchUrlArgs = z.infer<typeof fetchUrlSchema>;
async function fetchAllowedUrl(args: FetchUrlArgs): Promise<string> {
const { url } = args;
try {
const resp = await axios.get(url, {
timeout: 10_000,
headers: { "User-Agent": "OpenAI-Agent/1.0" },
});
// Truncate to avoid token overflow
const content = resp.data.slice(0, 8000);
console.log(`Fetched ${content.length} chars from ${url}`);
return content;
} catch (err: any) {
console.error(`HTTP error fetching ${url}:`, err.message);
throw new Error(`Failed to fetch ${url}: ${err.message}`);
}
}
// ----------------------------------------------------------------------
// OpenAI function definition
// ----------------------------------------------------------------------
const fetchUrlFunction = {
name: "fetch_allowed_url",
description:
"Fetch the contents of a whitelisted URL. Use only for trusted government or public data sources.",
parameters: fetchUrlSchema.shape,
};
// ----------------------------------------------------------------------
// Retry helper (exponential back‑off)
// ----------------------------------------------------------------------
async function retryable<T>(
fn: () => Promise<T>,
retries = 4,
minDelay = 2000,
maxDelay = 20000
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err: any) {
attempt++;
if (attempt > retries) throw err;
// Only retry on rate limit or network errors
const isRetryable =
err.code === "rate_limit_exceeded" ||
err.code === "ECONNABORTED" ||
err.code === "ETIMEDOUT";
if (!isRetryable) throw err;
const delay = Math.min(maxDelay, minDelay * 2 ** attempt) + Math.random() * 1000;
console.log(`Retry ${attempt}/${retries} after ${Math.round(delay)}ms…`);
await new Promise((res) => setTimeout(res, delay));
}
}
}
// ----------------------------------------------------------------------
// Core agent logic
// ----------------------------------------------------------------------
interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_call_id?: string;
}
async function runAgent(userQuery: string): Promise<string> {
const messages: ChatMessage[] = [
{
role: "system",
content:
"You are a helpful research assistant. When you need up‑to‑date factual data, you MUST call the `fetch_allowed_url` function with a URL from the allowed list. Never fabricate URLs or claim to have visited a site without using the tool.",
},
{ role: "user", content: userQuery },
];
// First call – let the model decide whether to use the tool
const firstResponse = await retryable(() =>
openai.createChatCompletion({
model: OPENAI_MODEL,
messages,
tools: [{ type: "function", function: fetchUrlFunction }],
tool_choice: "auto",
temperature: 0.2,
max_tokens: 1500,
})
);
const firstMsg = firstResponse.data.choices[0].message;
messages.push(firstMsg as ChatMessage); // preserve assistant message with possible tool_calls
if (firstMsg.tool_calls?.length) {
// Execute each tool call sequentially (you could parallelise if safe)
for (const toolCall of firstMsg.tool_calls) {
if (toolCall.function.name === "fetch_allowed_url") {
const args = JSON.parse(toolCall.function.arguments) as FetchUrlArgs;
let toolResult: string;
try {
toolResult = await fetchAllowedUrl(args);
} catch (err: any) {
toolResult = `Error fetching URL: ${err.message}`;
}
messages.push({
role: "tool",
tool_call_id: toolCall.id,
name: "fetch_allowed_url",
content: toolResult,
});
}
}
// Second call – give the model the tool output to craft the final answer
const secondResponse = await retryable(() =>
openai.createChatCompletion({
model: OPENAI_MODEL,
messages,
temperature: 0.2,
max_tokens: 1500,
})
);
return secondResponse.data.choices[0].message.content ?? "";
}
// No tool used
return firstMsg.content ?? "";
}
// ----------------------------------------------------------------------
// Simple CLI
// ----------------------------------------------------------------------
async function main() {
const [, , ...args] = process.argv;
if (args.length === 0) {
console.error('Usage: npx ts-node agent.ts "Your question here"');
process.exit(1);
}
const question = args.join(" ");
try {
const answer = await runAgent(question);
console.log("\n=== Assistant Answer ===\n");
console.log(answer);
} catch (err: any) {
console.error("\n❌ Agent failed:", err.message);
process.exit(1);
}
}
main();
How to run
Python:python agent.py "What is the latest unemployment rate according to the ABS?"
TS:npx ts-node agent.ts "What is the latest unemployment rate according to the ABS?"
Both scripts will:
ALLOWED_HOSTS.<a name="step-4-configuration"></a>
Create a .env file in the project root (never commit it to source control).
# .env – keep this file out of VCS (add to .gitignore)
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
OPENAI_MODEL=gpt-4-turbo-preview # optional – defaults to gpt-4-turbo-preview
# OPENAI_API_BASE=https://your-azure-openai-instance.openai.azure.com/ # Uncomment for Azure
Optional security hardening
| Setting | Purpose |
|---|---|
| API key restrictions (OpenAI dashboard) | Limit the key to specific models, disable unused endpoints. |
| IP allowlisting (if using a private link or Azure OpenAI) | Only allow traffic from your VPC/IP range. |
| Separate keys per environment | Dev, staging, prod each get their own key with scoped permissions. |
| Use a secrets manager | AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, etc., instead of plain .env in production. |
<a name="step-5-common-patterns"></a>
Below are reusable snippets you can drop into any project.
import { z } from "zod";
const QuerySchema = z.object({
question: z.string().min(1).max(500),
});
type Query = z.infer<typeof QuerySchema>;
function validateQuery(raw: string): Query {
return QuerySchema.parse({ question: raw });
}
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4-turbo-preview")
def count_tokens(text: str) -> int:
return len(enc.encode(text))
Use it to truncate prompts or fetched content before sending to the model.
stream = openai.ChatCompletion.create(
model="gpt-4-turbo-preview",
messages=[{"role":"user","content":"Tell me a story about a kangaroo"}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.get("content", "")
if delta:
print(delta, end="", flush=True)
print()
async function safeCall<T>(
fn: () => Promise<T>,
fallback: T,
context: string
): Promise<T> {
try {
return await fn();
} catch (e) {
console.error(`[${context}] Error:`, e);
return fallback;
}
}
Wrap each tool execution with safeCall to guarantee the agent never crashes because of a downstream failure.
OpenAI returns x-ratelimit-limit-requests and x-ratelimit-remaining-requests.
A simple middleware (Express) can pause when remaining < 5:
app.use(async (req, res, next) => {
const res = await openai.createChatCompletion({ /* … */ });
const remaining = Number(res.headers["x-ratelimit-remaining-requests"]);
if (remaining < 5) {
const reset = Number(res.headers["x-ratelimit-reset-requests"]);
const waitMs = Math.max(0, reset * 1000 - Date.now()) + 1000;
await new Promise(r => setTimeout(r, waitMs));
}
next();
});
<a name="step-6-troubleshooting"></a>
| Symptom | Likely Cause | Fix |
|---|---|---|
AuthenticationError: Invalid API key | Key missing, typo, or using wrong env var. | Double‑check .env spelling, ensure OPENAI_API_KEY is loaded (console.log(process.env.OPENAI_API_KEY) in dev – never log in prod). |
RateLimitError | Exceeded token or request quota. | Implement retry with back‑off (see code), monitor usage via OpenAI dashboard, consider requesting a higher tier or spreading load across multiple keys. |
APIConnectionError | Network issues, DNS, or proxy blocking outbound HTTPS. | Verify outbound connectivity to api.openai.com (port 443). If behind a corporate proxy, set HTTPS_PROXY env var. |
ValueError: URL not allowed | Attempt to fetch a disallowed domain. | Add the domain to ALLOWED_HOSTS only after a security review. Never add wildcard * unless you fully trust the source. |
| Empty or garbled assistant answer | Prompt too long, token limit exceeded, or temperature too high causing randomness. | Use tiktoken/gpt-3-encoder to count tokens, truncate fetched content, lower temperature (0.2‑0.4) for factual tasks. |
Function call returned invalid JSON | The model tried to call the function with malformed arguments. | Ensure the function schema is strict (additionalProperties: false) and that you validate arguments with Zod/Pydantic before using them. |
ModuleNotFoundError: openai (Python) | Package not installed in the active venv. | Activate the venv (source .venv/bin/activate) and re‑run pip install -r requirements.txt. |
Cannot find module 'openai' (TS/JS) | node_modules missing or TypeScript not configured. | Run npm install and ensure "type": "module" or "commonjs" matches your import/require style. Run tsc to compile before executing. |
Debug tip – Enable verbose logging for the OpenAI library:
import openai, logging
logging.basicConfig(level=logging.DEBUG)
openai.debug = True
// In TS, set environment variable before running:
export OPENAI_DEBUG=true
node -r ts-node/register agent.ts
<a name="step-7-production-checklist"></a>
| ✅ Item | Why it matters |
|---|---|
Secrets management – store OPENAI_API_KEY in a vault, CI/CD secret, or managed service (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault). | |
Least‑privilege API key – restrict the key to the exact model(s) and endpoints you need (e.g., disable edits, embeddings if unused). | |
| Input validation & allow‑listing – validate every user‑supplied string (URLs, filenames, etc.) before passing to the model or tools. | |
| Output sanitisation – strip or escape any HTML/JS if you render the assistant’s answer in a web page. | |
Rate‑limit handling – implement retry‑with‑back‑off and respect x-ratelimit-* headers; alert when approaching limits. | |
| Logging & audit – log requests (without the API key), tool calls, errors, and latency. Store logs in a secure, tamper‑evident system. | |
| Monitoring – set up alerts for spikes in token usage, error rates, or failed tool calls. | |
Version pinning – lock the OpenAPI library version (openai==1.35.0 for Python, openai@^4.44.0 for JS) and the model name (gpt-4-turbo-preview) to avoid surprise breaking changes. | |
Dependency scanning – run npm audit / pip check and keep dependencies up‑to‑date. | |
Network security – if possible, place outbound calls behind a proxy or VPC endpoint that only allows api.openai.com. | |
| Content moderation – run the model’s output through OpenAI’s moderation endpoint or a custom profanity filter to catch policy‑violating text. | |
| Fail‑safe defaults – if a tool call fails, return a generic, safe response (“I’m unable to retrieve that information at the moment”) rather than exposing internal errors. | |
| Regular key rotation – rotate API keys periodically (e.g., every 30 days) and update all services automatically. | |
| Disaster recovery – have a fallback (e.g., a static FAQ or a different LLM provider) if the OpenAI service becomes unavailable. | |
| Legal & compliance – ensure your use case complies with data‑protection regulations (GDPR, Australian Privacy Principles) – especially when handling personal data retrieved from government sites. | |
| Testing – write unit tests for validation logic, integration tests (with mocked OpenAI responses), and chaos tests for network failures. | |
Documentation – keep a README that explains how to run the agent, how to add new allowed hosts, and how to rotate keys. |
You now have a complete, production‑ready foundation for building AI agents that can safely interact with external data sources—exactly the kind of safeguards that could have prevented the kind of breach described in the news story.
Feel free to extend the fetch_allowed_url tool with other secure functions (e.g., a database query wrapper, a file‑system sandbox, or a rate‑limited API to your own micro‑service). Always keep the allow‑list principle at the heart of any tool you expose to the model.
Happy coding, and stay secure! 🚀
Source: The Verge AI
Follow ICARAX for more AI insights and tutorials.
