Learnixo

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:

  1. Read-only DB users for search/lookup tools; write users only for tools that must write
  2. Scoped API keys per tool, not one global key
  3. Role-based tool sets — users get only the tools their role requires
  4. 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:

SQL
-- 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:

Python
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.

Python
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.

Python
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 only

Runtime 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.

Python
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:

Python
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 |