0Pricing
AI Agents · Lesson

Policy Enforcement for Agent Actions

Pre-action policy checks, allowlists/denylists, and dynamic policy rules.

Policy Enforcement for Agent Actions is a free AI Agents lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the AI Agents learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

What Is Agent Policy Enforcement?

Policy enforcement is the runtime gate that runs before every agent action to decide whether the action is permitted. Without it, the agent's only constraint is the LLM's instruction-following — which can be bypassed or misinterpreted.

Enforcement must be outside the LLM, in your infrastructure.

The Pre-Action Check Pattern

Before executing any tool, call can_agent_do(action, context). This function is the single enforcement point — every path to action execution passes through it.

def can_agent_do(action: str, context: dict) -> tuple[bool, str]:
    '''
    Returns (allowed: bool, reason: str).
    Context includes: user_id, agent_id, session_id, parameters, timestamp.
    '''
    # 1. Check denylist first (fast path for obvious violations)
    if action in DENIED_ACTIONS:
        return False, f'Action "{action}" is on the global denylist'

    # 2. Check allowlist
    if action not in ALLOWED_ACTIONS:
        return False, f'Action "{action}" is not on the allowlist'

    # 3. Context-specific checks
    return check_context_policy(action, context)

Defining Allowlists and Denylists

The allowlist enumerates every action the agent is permitted to take. Anything not on the list is blocked by default. The denylist adds an extra safety net for actions that should never be allowed regardless of context.

# Allowlist: tools the agent can use
ALLOWED_ACTIONS = {
    'web_search',
    'read_file',
    'write_file',
    'send_email',
    'create_calendar_event',
    'query_database',
    'execute_python_sandbox',
    'fetch_url',
    'create_ticket'
}

# Denylist: actions that are always blocked, regardless of context
DENIED_ACTIONS = {
    'delete_all_records',
    'export_entire_database',
    'send_mass_email',
    'modify_system_config',
    'create_admin_user',
    'disable_audit_logging'
}

if __name__ == '__main__':
    for action in ('web_search', 'send_mass_email'):
        print(f"{action}: allowed={action in ALLOWED_ACTIONS} denied={action in DENIED_ACTIONS}")

Context-Specific Policy Checks

Beyond simple allow/deny lists, policies often depend on context: who is the user, what is their role, what time is it, what is the target resource? These are context-specific checks.

from datetime import datetime, timezone

def check_context_policy(action: str, context: dict) -> tuple[bool, str]:
    user_id   = context.get('user_id', '')
    params    = context.get('parameters', {})
    user_role = context.get('user_role', 'user')

    # send_email: only agents with email_sender role
    if action == 'send_email':
        if user_role not in ('email_agent', 'admin'):
            return False, f'Role "{user_role}" cannot send emails'
        recipient = params.get('to', '')
        if not recipient.endswith('@trusted-domain.com'):
            return False, 'Email recipient must be in @trusted-domain.com'

    # write_file: path restrictions
    if action == 'write_file':
        path = params.get('path', '')
        if not path.startswith('/tmp/') and not path.startswith('/workspace/'):
            return False, f'File writes outside /tmp/ and /workspace/ are not allowed'

    return True, 'Permitted'

if __name__ == '__main__':
    ctx = {'user_id': 'u1', 'user_role': 'user',
           'parameters': {'to': 'someone@gmail.com'}}
    print('send_email as user:', check_context_policy('send_email', ctx))

    ctx2 = {'user_id': 'u1', 'user_role': 'user',
            'parameters': {'path': '/etc/passwd'}}
    print('write_file outside sandbox:', check_context_policy('write_file', ctx2))

Dynamic Policies from a Policy Engine

Hard-coded policies are hard to update in production. Use a policy engine (like OPA — Open Policy Agent) to evaluate policies defined as data, not code. Policies can be updated without redeploying the agent.

import requests

OPA_URL = 'http://localhost:8181/v1/data/agent/allow'

def opa_policy_check(action: str, context: dict) -> tuple[bool, str]:
    payload = {
        'input': {
            'action':   action,
            'user_id':  context.get('user_id'),
            'role':     context.get('user_role', 'user'),
            'params':   context.get('parameters', {}),
            'time_utc': datetime.now(timezone.utc).isoformat()
        }
    }
    try:
        resp = requests.post(OPA_URL, json=payload, timeout=0.5)
        result = resp.json().get('result', {})
        allowed = result.get('allow', False)
        reason  = result.get('reason', 'Policy decision')
        return allowed, reason
    except Exception as e:
        # Fail closed: deny if policy engine is unreachable
        return False, f'Policy engine unavailable: {e}'

Fail-Closed vs Fail-Open

When the policy engine is unavailable, you have two options:

  • Fail-closed: deny all actions. Safe, but the agent stops working.
  • Fail-open: allow all actions. The agent keeps working, but policy is unenforced.

For security-sensitive agents, always fail-closed. For productivity agents with low-risk actions, fail-open may be acceptable.

FAIL_CLOSED = True  # Configure per agent

def safe_policy_check(action: str, context: dict) -> tuple[bool, str]:
    try:
        return can_agent_do(action, context)
    except Exception as e:
        if FAIL_CLOSED:
            return False, f'Policy check failed (fail-closed): {e}'
        else:
            # Log the failure but allow the action
            import logging
            logging.warning('Policy check error (fail-open): %s', e)
            return True, 'Policy check bypassed due to error (fail-open)'

Rate Limiting Actions

Policy enforcement can include rate limits: an agent may be allowed to send emails, but only 5 per session. Exceed the limit and the action is denied.

from collections import defaultdict
import time

action_counts: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
action_window_start: dict[str, float] = defaultdict(float)

ACTION_RATE_LIMITS = {
    'send_email':   {'limit': 5,  'window_secs': 3600},  # 5/hour
    'write_file':   {'limit': 50, 'window_secs': 300},
    'web_search':   {'limit': 20, 'window_secs': 60}
}

def check_rate_limit(action: str, session_id: str) -> tuple[bool, str]:
    limit_config = ACTION_RATE_LIMITS.get(action)
    if not limit_config:
        return True, 'No rate limit defined'

    window = limit_config['window_secs']
    now = time.time()
    key = f'{session_id}:{action}'

    if now - action_window_start[key] > window:
        action_counts[key] = defaultdict(int)
        action_window_start[key] = now

    action_counts[key]['count'] += 1
    if action_counts[key]['count'] > limit_config['limit']:
        return False, f'Rate limit exceeded: {action} ({limit_config["limit"]}/{window}s)'
    return True, 'Within rate limit'

if __name__ == '__main__':
    for i in range(6):
        allowed, reason = check_rate_limit('send_email', 'session-1')
    print(f'After 6 send_email calls: allowed={allowed}, reason={reason}')

Policy Violation Logging

Every policy denial must be logged with full context. These logs are the first place to look when debugging unexpected agent behavior or investigating a security incident.

import logging, json, time

policy_logger = logging.getLogger('agent.policy')

def enforced_action(agent_id: str, user_id: str, action: str,
                    context: dict, audit_log) -> tuple[bool, str]:
    allowed, reason = safe_policy_check(action, context)

    log_entry = {
        'ts':       time.time(),
        'agent_id': agent_id,
        'user_id':  user_id,
        'action':   action,
        'allowed':  allowed,
        'reason':   reason,
        'params':   context.get('parameters', {})
    }

    if allowed:
        policy_logger.info('ALLOWED %s', json.dumps(log_entry))
    else:
        policy_logger.warning('DENIED %s', json.dumps(log_entry))

    audit_log.append(
        agent_id, user_id,
        f'POLICY_{"ALLOW" if allowed else "DENY"}',
        {'action': action},
        {'allowed': allowed, 'reason': reason},
        context.get('session_id', '')
    )
    return allowed, reason

Resource Scoping

Even for allowed actions, scope the resource the agent can access. An agent handling user A's documents should not be able to read user B's files, even if read_file is on the allowlist.

def check_resource_scope(action: str, context: dict) -> tuple[bool, str]:
    user_id  = context.get('user_id', '')
    params   = context.get('parameters', {})

    if action == 'read_file':
        path = params.get('path', '')
        # Each user's files must be under their own namespace
        if not path.startswith(f'/workspace/{user_id}/'):
            return False, (
                f'User {user_id} cannot read files outside '
                f'/workspace/{user_id}/'
            )

    if action == 'query_database':
        table = params.get('table', '')
        allowed_tables = {'products', 'public_docs', f'user_{user_id}_data'}
        if table not in allowed_tables:
            return False, f'Table "{table}" not in scope for user {user_id}'

    return True, 'Resource scope check passed'

if __name__ == '__main__':
    ctx = {'user_id': 'u1', 'parameters': {'path': '/workspace/u2/secret.txt'}}
    print('Cross-user file read:', check_resource_scope('read_file', ctx))

    ctx2 = {'user_id': 'u1', 'parameters': {'path': '/workspace/u1/notes.txt'}}
    print('Own file read:      ', check_resource_scope('read_file', ctx2))

Testing Policy Rules

Policy rules are code — they must be tested. Write unit tests for each rule to ensure denials and allowances work correctly and edge cases do not create accidental policy bypasses.

def test_policy_rules():
    # Denylist blocks unconditionally
    ok, msg = can_agent_do('delete_all_records', {'user_role': 'admin'})
    assert not ok, 'Denylist should block even for admin'

    # Email requires trusted domain
    ok, msg = can_agent_do('send_email', {
        'user_role': 'email_agent',
        'parameters': {'to': 'attacker@evil.com'}
    })
    assert not ok, 'Should block untrusted email recipient'

    # File write outside allowed paths
    ok, msg = can_agent_do('write_file', {
        'user_role': 'user',
        'parameters': {'path': '/etc/crontab'}
    })
    assert not ok, 'Should block write to /etc/'

    print('All policy tests passed')

test_policy_rules()

Caching Policy Decisions

Calling the policy engine on every single action adds latency, especially when using an external OPA service. Cache recent decisions for (action, context_hash) pairs with a short TTL to reduce round-trips.

import hashlib, time

policy_cache: dict[str, dict] = {}
POLICY_CACHE_TTL = 10  # seconds — short TTL so policy updates take effect quickly

def cached_policy_check(action: str, context: dict) -> tuple[bool, str]:
    ctx_hash = hashlib.md5(
        f'{action}:{context.get("user_id")}:{context.get("user_role")}'
        .encode()
    ).hexdigest()
    key = f'{action}:{ctx_hash}'
    entry = policy_cache.get(key)
    if entry and time.time() - entry['ts'] < POLICY_CACHE_TTL:
        return entry['result']
    result = can_agent_do(action, context)
    policy_cache[key] = {'result': result, 'ts': time.time()}
    return result

What is the 'fail-closed' approach to policy enforcement?

The fail-closed vs fail-open decision is a fundamental security tradeoff for any policy enforcement system. Knowing when each approach is appropriate is a core governance concept.

Policy Enforcement Recap

Agent policy enforcement uses: a pre-action check as the single gate, allowlists + denylists for base decisions, context-specific rules for resource scoping and role checks, a dynamic policy engine (OPA) for updatable rules, rate limits per action per session, and fail-closed defaults for security-critical agents.

Frequently asked questions

Is the “Policy Enforcement for Agent Actions” lesson free?

Yes — the full text of “Policy Enforcement for Agent Actions” is free to read here on the web, and the AI Agents course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the AI Agents course, upgrade to CoddyKit PRO.

What will I learn in “Policy Enforcement for Agent Actions”?

Pre-action policy checks, allowlists/denylists, and dynamic policy rules. You practise AI Agents with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start AI Agents?

No prior experience is required. AI Agents on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Policy Enforcement for Agent Actions” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this AI Agents lesson?

Yes. Every AI Agents lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Immutable Action Logging for Agents
  2. Policy Enforcement for Agent Actions
  3. Regulatory Compliance: GDPR and SOC2
  4. Human-in-the-Loop Approval Gates
← Back to AI Agents