DEV Community

Quinn Li
Quinn Li

Posted on

A Reproducible Tool-Call Gatekeeper for AI Agents

Autonomous agents are often connected to real tools: shell commands, email clients, database handles, and file operations. A single unsafe call can delete data or send an external message, so teams add a policy layer between the agent and the executor.

This article builds a small, testable tool-call gatekeeper. It uses deterministic allow and deny rules first, then falls back to an LLM classifier for unknown tool calls. The prototype is written to run against MonkeyCode's free model access and free server option during local experiments. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

What the gatekeeper does

The gatekeeper receives a proposed tool call, checks it against a local policy file, and returns one of three decisions:

  • allow: the call is safe to forward to the executor.
  • deny: the call is blocked before execution.
  • escalate: the call needs human review.

Deterministic rules handle known tools, while the LLM fallback handles only the long tail. That separation makes the behavior easier to debug than a single end-to-end classifier.

Step 1: Create the project and install dependencies

mkdir agent-gatekeeper
cd agent-gatekeeper
python -m venv .venv
source .venv/bin/activate
pip install requests
Enter fullscreen mode Exit fullscreen mode

Step 2: Define a readable policy file

Create policy.json:

{
  "version": 1,
  "default_decision": "escalate",
  "allowlist": ["read_file", "list_directory", "search_docs"],
  "denylist": ["delete_file", "drop_table", "send_email"],
  "llm_fallback": {
    "enabled": true,
    "timeout_seconds": 10,
    "max_tokens": 120
  }
}
Enter fullscreen mode Exit fullscreen mode

The default decision matters when the LLM fallback is disabled or returns malformed output. Escalating unknown calls is safer than allowing them by default.

Step 3: Implement the gatekeeper

Create gatekeeper.py:

import argparse
import json
import os

import requests


def load_policy(path):
    with open(path, 'r', encoding='utf-8') as handle:
        return json.load(handle)


def deterministic_decision(policy, tool_name):
    if tool_name in policy.get('denylist', []):
        return {'decision': 'deny', 'reason': 'denylist match'}
    if tool_name in policy.get('allowlist', []):
        return {'decision': 'allow', 'reason': 'allowlist match'}
    return None


def classify_with_llm(tool_call, policy, env):
    url = env['GATE_LLM_URL']
    headers = {'Content-Type': 'application/json'}
    if env.get('GATE_LLM_API_KEY'):
        headers['Authorization'] = 'Bearer ' + env['GATE_LLM_API_KEY']

    system_message = (
        'You are a tool-call policy classifier. Reply with JSON only. '
        'Choose allow, deny, or escalate. Deny destructive or high-risk calls. '
        'Escalate when intent is unclear.'
    )
    payload = {
        'model': env.get('GATE_LLM_MODEL', 'default'),
        'temperature': 0,
        'messages': [
            {'role': 'system', 'content': system_message},
            {'role': 'user', 'content': json.dumps(tool_call)}
        ],
        'max_tokens': policy['llm_fallback']['max_tokens']
    }

    response = requests.post(
        url,
        headers=headers,
        json=payload,
        timeout=policy['llm_fallback']['timeout_seconds']
    )
    response.raise_for_status()
    content = response.json()['choices'][0]['message']['content']
    parsed = json.loads(content)
    return {
        'decision': parsed['decision'],
        'reason': parsed.get('reason', 'llm fallback')
    }


def evaluate(tool_call, policy, env):
    tool_name = tool_call.get('name', '')
    result = deterministic_decision(policy, tool_name)
    if result is not None:
        return result

    fallback = policy.get('llm_fallback', {})
    if not fallback.get('enabled', True):
        return {
            'decision': policy.get('default_decision', 'escalate'),
            'reason': 'default policy, fallback disabled'
        }

    try:
        return classify_with_llm(tool_call, policy, env)
    except Exception as exc:
        return {
            'decision': policy.get('default_decision', 'escalate'),
            'reason': 'llm error: ' + str(exc)
        }


def main():
    parser = argparse.ArgumentParser(description='Evaluate a proposed tool call')
    parser.add_argument('--policy', default='policy.json')
    parser.add_argument('--tool-call', required=True)
    parser.add_argument('--dry-run', action='store_true')
    args = parser.parse_args()

    with open(args.tool_call, 'r', encoding='utf-8') as handle:
        tool_call = json.load(handle)

    policy = load_policy(args.policy)
    env = {
        'GATE_LLM_URL': os.environ.get('GATE_LLM_URL', ''),
        'GATE_LLM_MODEL': os.environ.get('GATE_LLM_MODEL', ''),
        'GATE_LLM_API_KEY': os.environ.get('GATE_LLM_API_KEY', '')
    }

    result = evaluate(tool_call, policy, env)
    if args.dry_run:
        print('DRY RUN')
    print(json.dumps(result, indent=2))


if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

The try/except around the LLM call escalates on timeout or malformed JSON instead of failing open.

Step 4: Configure the endpoint

export GATE_LLM_URL='https://your-endpoint-here.example/chat/completions'
export GATE_LLM_MODEL='the-model-id-from-the-provider-dashboard'
export GATE_LLM_API_KEY='your-api-key-here'
python gatekeeper.py --policy policy.json --tool-call samples/delete_file.json --dry-run
Enter fullscreen mode Exit fullscreen mode

The URL path, model identifier, and authentication format must come from the provider's current documentation. The gatekeeper does not hard-code those values. For a non-OpenAI-compatible API, the request payload and response parsing inside classify_with_llm should be replaced.

Step 5: Run a small test plan

Create three sample files.

samples/read_file.json:

{
  "name": "read_file",
  "arguments": {"path": "/tmp/notes.txt"},
  "agent_id": "agent-1",
  "intent": "load notes for a summary"
}
Enter fullscreen mode Exit fullscreen mode

samples/delete_file.json:

{
  "name": "delete_file",
  "arguments": {"path": "/tmp/cache.txt"},
  "agent_id": "agent-1",
  "intent": "remove cache file"
}
Enter fullscreen mode Exit fullscreen mode

samples/unknown_query.json:

{
  "name": "run_sql",
  "arguments": {"query": "SELECT COUNT(*) FROM events"},
  "agent_id": "agent-1",
  "intent": "check event volume"
}
Enter fullscreen mode Exit fullscreen mode

Run the deterministic cases first:

python gatekeeper.py --policy policy.json --tool-call samples/read_file.json --dry-run
python gatekeeper.py --policy policy.json --tool-call samples/delete_file.json --dry-run
Enter fullscreen mode Exit fullscreen mode

Expected output: allow for the read, deny for the delete, without an LLM request.

Then run the unknown case against the configured LLM endpoint. The classifier should return allow, deny, or escalate with a plain-text reason. If the endpoint is unavailable, the gatekeeper should return escalate rather than crash.

Why the fallback should be conservative

An LLM classifier is useful for unusual tool names, but it is not a security boundary. The model can be confused by indirect instructions, prompt injection, or an unfamiliar tool. For that reason, the policy escalates on malformed output and keeps a short denylist for the riskiest actions.

Limitations

  • The code assumes an OpenAI-compatible chat completions response shape. Other APIs need a different parser.
  • Free model access and free server options may have rate limits, latency, and usage caps. This article does not test those limits and does not claim specific quotas or guaranteed availability.
  • The classifier is probabilistic. Identical requests can produce different results if the provider changes the model or sampling settings.
  • The prototype is synchronous and not suitable for high-throughput tool traffic.

Who should not use this approach

This pattern is not appropriate when the gatekeeper protects high-privilege production actions such as database deletion, cloud resource teardown, or customer-facing messaging. It is not a replacement for access control, audit logs, or signed tool allowlists. The gatekeeper is a prototyping aid, not a hardened security control.

Closing

The full gatekeeper is small enough to port into an existing agent harness. Replace the endpoint with a documented free server during early experiments, keep the dry-run mode enabled, and review escalated calls by hand before opening any real tool.

Top comments (0)