Agents & Tools Interview Prep · Lesson 10 of 12
Principle of Least Privilege for Agent Tools
The Principle Applied to AI Tools
Least privilege means: every component gets only the permissions it needs to do its job — nothing more.
For tool-calling agents this translates to four concrete practices:
- Read-only DB users for search/lookup tools; write users only for tools that must write
- Scoped API keys per tool, not one global key
- Role-based tool sets — users get only the tools their role requires
- Runtime enforcement — check permissions at tool execution time, not just at registration
An agent that only needs to search a drug database should never have a connection string that can INSERT, UPDATE, or DELETE. If the agent is compromised or manipulated, the blast radius is limited by its permissions.
Read-Only DB Users for Search Tools
Create separate PostgreSQL users for different tool categories:
-- Read-only user for search and lookup tools
CREATE USER agent_readonly WITH PASSWORD 'readonly_password';
GRANT CONNECT ON DATABASE pharmacy_db TO agent_readonly;
GRANT USAGE ON SCHEMA public TO agent_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_readonly;
-- Explicitly deny writes
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA public FROM agent_readonly;
-- Write user for prescription tools
CREATE USER agent_prescriptions WITH PASSWORD 'prescriptions_password';
GRANT CONNECT ON DATABASE pharmacy_db TO agent_prescriptions;
GRANT USAGE ON SCHEMA public TO agent_prescriptions;
GRANT SELECT ON drugs, patients, formulary TO agent_prescriptions;
GRANT INSERT, UPDATE ON prescriptions TO agent_prescriptions;
-- No access to audit logs, admin tables, or other tools' tables
REVOKE ALL ON audit_log, admin_config FROM agent_prescriptions;
-- Audit user for logging tools only
CREATE USER agent_audit WITH PASSWORD 'audit_password';
GRANT CONNECT ON DATABASE pharmacy_db TO agent_audit;
GRANT INSERT ON audit_log TO agent_audit;
GRANT SELECT ON audit_log TO agent_audit;
REVOKE ALL ON drugs, patients, prescriptions FROM agent_audit;In Python, each tool function uses its own connection string:
import asyncpg
import os
# Different connection strings per tool category
READONLY_DSN = os.environ["DB_DSN_READONLY"] # agent_readonly user
PRESCRIPTIONS_DSN = os.environ["DB_DSN_PRESCRIPTIONS"] # agent_prescriptions user
AUDIT_DSN = os.environ["DB_DSN_AUDIT"] # agent_audit user
async def search_drug(query: str) -> dict:
"""Uses read-only connection — cannot modify data even if manipulated."""
conn = await asyncpg.connect(READONLY_DSN)
try:
rows = await conn.fetch(
"SELECT drug_id, brand_name, generic_name FROM drugs WHERE LOWER(brand_name) LIKE $1",
f"%{query.lower()}%"
)
return {"results": [dict(r) for r in rows]}
finally:
await conn.close()
async def create_prescription(patient_id: str, drug_id: str, dose: str) -> dict:
"""Uses write connection — but only for the prescriptions table."""
conn = await asyncpg.connect(PRESCRIPTIONS_DSN)
try:
prescription_id = await conn.fetchval(
"""
INSERT INTO prescriptions (patient_id, drug_id, dose, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING prescription_id
""",
patient_id, drug_id, dose
)
return {"success": True, "prescription_id": prescription_id}
finally:
await conn.close()Scoped API Keys Per Tool
Never use one master API key for all tools. Create separate keys with minimal scopes.
import os
from dataclasses import dataclass
@dataclass
class ToolConfig:
"""Configuration for a single tool — including its own API credentials."""
name: str
api_key: str
api_endpoint: str
allowed_methods: list[str] # GET-only for read tools
# Each tool gets its own key loaded from environment
TOOL_CONFIGS = {
"search_drug_interactions": ToolConfig(
name="search_drug_interactions",
api_key=os.environ["RXNORM_READONLY_KEY"], # Read-only key
api_endpoint="https://rxnav.nlm.nih.gov",
allowed_methods=["GET"]
),
"send_clinical_alert": ToolConfig(
name="send_clinical_alert",
api_key=os.environ["MESSAGING_SEND_ONLY_KEY"], # Send-only key, no read
api_endpoint="https://alerts.hospital.internal",
allowed_methods=["POST"]
),
"get_lab_results": ToolConfig(
name="get_lab_results",
api_key=os.environ["LAB_SYSTEM_READONLY_KEY"], # Lab system read-only
api_endpoint="https://labs.hospital.internal",
allowed_methods=["GET"]
),
}
import httpx
def execute_api_tool(tool_name: str, method: str, path: str, **kwargs) -> dict:
"""Execute an API call using the tool's scoped key and enforce method allowlist."""
config = TOOL_CONFIGS.get(tool_name)
if not config:
return {"error": f"Tool '{tool_name}' has no API configuration"}
if method.upper() not in config.allowed_methods:
return {
"error": f"Method {method} not allowed for tool '{tool_name}'",
"allowed_methods": config.allowed_methods
}
url = f"{config.api_endpoint}{path}"
headers = {"Authorization": f"Bearer {config.api_key}"}
try:
with httpx.Client() as client:
response = client.request(
method=method,
url=url,
headers=headers,
**kwargs
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
return {"error": str(e)}Role-Based Tool Sets
Different user roles see different tool sets. A viewer cannot even request a write tool — the LLM doesn't know it exists.
from enum import Enum
from dataclasses import dataclass, field
class UserRole(str, Enum):
viewer = "viewer"
nurse = "nurse"
physician = "physician"
pharmacist = "pharmacist"
admin = "admin"
# Define tool schemas
SEARCH_DRUG_SCHEMA = {
"type": "function",
"function": {
"name": "search_drug",
"description": "Search the drug formulary.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
}
}
GET_PATIENT_SCHEMA = {
"type": "function",
"function": {
"name": "get_patient_record",
"description": "Get a patient's medical record.",
"parameters": {
"type": "object",
"properties": {"patient_id": {"type": "string"}},
"required": ["patient_id"]
}
}
}
CREATE_PRESCRIPTION_SCHEMA = {
"type": "function",
"function": {
"name": "create_prescription",
"description": "Create a new prescription for a patient.",
"parameters": {
"type": "object",
"properties": {
"patient_id": {"type": "string"},
"drug_id": {"type": "string"},
"dose": {"type": "string"}
},
"required": ["patient_id", "drug_id", "dose"]
}
}
}
DELETE_RECORD_SCHEMA = {
"type": "function",
"function": {
"name": "delete_patient_record",
"description": "Permanently delete a patient record.",
"parameters": {
"type": "object",
"properties": {"patient_id": {"type": "string"}},
"required": ["patient_id"]
}
}
}
# Role-to-tools mapping
ROLE_TOOL_SETS: dict[UserRole, list] = {
UserRole.viewer: [
SEARCH_DRUG_SCHEMA,
# No patient record access
],
UserRole.nurse: [
SEARCH_DRUG_SCHEMA,
GET_PATIENT_SCHEMA,
# No prescribing tools
],
UserRole.physician: [
SEARCH_DRUG_SCHEMA,
GET_PATIENT_SCHEMA,
CREATE_PRESCRIPTION_SCHEMA,
],
UserRole.pharmacist: [
SEARCH_DRUG_SCHEMA,
GET_PATIENT_SCHEMA,
CREATE_PRESCRIPTION_SCHEMA,
],
UserRole.admin: [
SEARCH_DRUG_SCHEMA,
GET_PATIENT_SCHEMA,
CREATE_PRESCRIPTION_SCHEMA,
DELETE_RECORD_SCHEMA, # Only admins can delete
],
}
def get_tools_for_user(user_role: UserRole) -> list:
"""Return only the tools this user role is authorized to use."""
return ROLE_TOOL_SETS.get(user_role, [SEARCH_DRUG_SCHEMA]) # Default: viewer onlyRuntime Access Control: Enforce at Execution
Role-based tool sets prevent the LLM from requesting unauthorized tools. But you also need to enforce permissions at execution time — in case of bugs or edge cases in the tool set assignment.
import openai
import json
import logging
logger = logging.getLogger(__name__)
# Map tool names to required roles
TOOL_REQUIRED_ROLES = {
"search_drug": [UserRole.viewer, UserRole.nurse, UserRole.physician, UserRole.pharmacist, UserRole.admin],
"get_patient_record": [UserRole.nurse, UserRole.physician, UserRole.pharmacist, UserRole.admin],
"create_prescription": [UserRole.physician, UserRole.pharmacist, UserRole.admin],
"delete_patient_record": [UserRole.admin],
}
def authorized_execute_tool(
tool_call,
tool_map: dict,
current_user_role: UserRole,
user_id: str
) -> dict:
"""Execute a tool call with runtime authorization check."""
fn_name = tool_call.function.name
# Check runtime authorization
allowed_roles = TOOL_REQUIRED_ROLES.get(fn_name, [])
if current_user_role not in allowed_roles:
logger.warning(
"Unauthorized tool access attempt",
extra={
"tool": fn_name,
"user_id": user_id,
"user_role": current_user_role.value,
"required_roles": [r.value for r in allowed_roles]
}
)
return {
"error": "Access denied",
"tool": fn_name,
"message": f"Your role ({current_user_role.value}) does not have permission to use this tool."
}
if fn_name not in tool_map:
return {"error": f"Tool '{fn_name}' not implemented"}
fn_args = json.loads(tool_call.function.arguments)
return tool_map[fn_name](**fn_args)
# Agent function that enforces both tool set AND runtime checks
def run_authorized_agent(user_message: str, user_id: str, user_role: UserRole) -> str:
client = openai.OpenAI()
# Give the LLM only the tools it's allowed to know about
available_tools = get_tools_for_user(user_role)
messages = [
{
"role": "system",
"content": f"You are a clinical assistant for a {user_role.value}. Use available tools to help."
},
{"role": "user", "content": user_message}
]
for _ in range(6):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=available_tools,
tool_choice="auto"
)
msg = response.choices[0].message
if not msg.tool_calls:
return msg.content or ""
messages.append(msg)
for tc in msg.tool_calls:
# Runtime check — belt and suspenders
result = authorized_execute_tool(
tool_call=tc,
tool_map=GLOBAL_TOOL_MAP,
current_user_role=user_role,
user_id=user_id
)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result)
})
return "Request could not be completed."Environment Variable Management for Tool Credentials
Never hardcode API keys or connection strings. Use environment variables and validate their presence at startup:
import os
import sys
REQUIRED_ENV_VARS = {
"DB_DSN_READONLY": "Read-only database connection string",
"DB_DSN_PRESCRIPTIONS": "Prescription DB connection string",
"RXNORM_READONLY_KEY": "RxNorm API read-only key",
"MESSAGING_SEND_ONLY_KEY": "Clinical messaging API key",
}
def validate_tool_credentials() -> None:
"""Call this at application startup. Fail fast if credentials are missing."""
missing = []
for var, description in REQUIRED_ENV_VARS.items():
if not os.environ.get(var):
missing.append(f" {var}: {description}")
if missing:
print("FATAL: Missing required environment variables for tool credentials:")
for m in missing:
print(m)
sys.exit(1)
# Call at startup
validate_tool_credentials()Least Privilege Checklist
| Layer | Principle | Implementation | |---|---|---| | Database | Read-only user for read tools | Separate DB users per tool category | | Database | Write user scoped to minimum tables | GRANT only needed tables | | External APIs | Separate API key per tool | Per-tool key in TOOL_CONFIGS | | LLM context | Tools filtered by user role | ROLE_TOOL_SETS map | | Runtime | Re-check authorization before execution | TOOL_REQUIRED_ROLES enforcement | | Credentials | No hardcoded secrets | Environment variables, validated at startup | | Audit | All tool calls logged with user context | Structured logging with user_id, role, tool |