← Back

From "Can It?" to "Should It?" — Part I

Building a Contextual Authorization Layer for AI Agents

Why static IAM is failing autonomous systems, and how to build a stateful security layer.

The uncomfortable problem

Consider two commands:

rm -rf ./build

and:

rm -rf /

Same primitive operation. Same binary. At a basic authorization layer, the question is essentially whether the user has permission to modify the target path.

Yet the security meaning is completely different.

Traditional authorization is very good at answering can this principal perform this action? It is much less good at answering should this action be performed right now, against this resource, with this data, in this environment, given what has already happened?

That gap has always existed. It was survivable largely because humans are slow, have judgment, notice when something looks wrong, and generally stop to think before doing something catastrophic.

Agents don't have that property by default. They can execute repeatedly, quickly, across multiple systems, following an objective without understanding the consequences the way a human does.

The problem isn't that agents are intelligent. The problem is that we're giving machine-speed software permissions designed around human behaviour.

Why agents expose this weakness

Classic authorization can be represented as:

Human
  ↓
Permission (scope|priviledge|environment|in-time)
  ↓
Resource

An agent-mediated action looks more like:

Human
  ↓
Agent
  ↓
Tool
  ↓
Action
  ↓
Resource
  ↓
Data

Every additional layer is a place where the original human intent can be reinterpreted, and those layers can be exercised at machine speed.

A person with s3:DeleteObject might use it once a month, deliberately, after thinking about what they're deleting. An agent with the same permission might call it fifty times in ten seconds because an instruction told it to "clean up the bucket." The permission didn't change. The blast radius did.

That is the reason agentic systems need more context in the authorization decision than static IAM was designed to carry.

What existing security already does well

It is worth being honest about this. This isn't an argument that existing security controls are inadequate at everything they do.

  • EDR provides visibility into endpoint activity.
  • DLP understands data movement.
  • IAM handles identity and permissions.
  • MCP authorization provides authentication and authorization mechanisms between clients and tool servers.
  • SIEM correlates security events.

Each of these solves an important part of the problem. The gap is in the space between those signals and an enforcement decision — something that answers, given everything we know about this agent and this action, whether the action should be allowed. That is the layer I wanted to explore.

Defining the authorization problem

I started treating authorization not as a property of an action, but as a property of an action in context.

The model is:

Decision =
f(
    user,
    agent,
    action,
    resource,
    data,
    session_state,
    environment
)

with three possible outcomes:

ALLOW
ESCALATE
DENY

ESCALATE is important. It isn't a failure of automation. It is a deliberate outcome for cases where the system has enough information to know that an action deserves scrutiny, but not enough confidence to safely allow or deny it automatically.

A binary allow/deny model forces ambiguous cases into one of two extremes: allow too much, or block too much. Both are bad for real systems. An explicit escalation path makes human judgment part of the control rather than treating it as a failure of automation.

Don't trust the authorization request

This ended up being one of the most important implementation lessons.

Early versions of the policy decision point accepted security-relevant facts from the authorization request itself. For example:

request says:
    category = read
request says:
    environment = local
request says:
    consumes = []

That is a trust-boundary failure. If the thing asking for authorization can also define the facts that determine whether it should be authorized, then authorization becomes self-attestation. An agent could effectively say "this is a read operation, against a local resource, with no sensitive data involved" — and the PDP would have no reason to disagree.

An authorization request is evidence, not truth.

Security-relevant facts need to be derived or independently validated. The requested operation determines its semantic category. Resource resolution determines the environment. Session state belongs to the PDP rather than the caller.

This distinction became important enough that several early bypasses were fixed specifically at this trust boundary.

From tool permissions to semantic permissions

Different systems express the same intent in completely different ways:

delete_rds_instance
DROP DATABASE
aws s3 rb
rm -rf

A policy written around every individual tool quickly becomes an unmaintainable list of vendor-specific exceptions. Instead, the system first normalizes operations into semantic categories:

delete_rds_instance ─┐
DROP DATABASE        ├──→ DESTRUCTIVE
aws s3 rb            │
rm -rf               ┘

Now a policy can say "no destructive actions in production" rather than requiring a separate rule for every database, cloud provider, CLI, and agent tool.

This doesn't eliminate ambiguity — it moves it into a controlled normalization layer. Unknown or unresolvable operations should remain unknown rather than being optimistically classified. A classifier is part of the security boundary too.

Authorization needs memory

This is probably the centerpiece of the system.

Consider these actions individually:

read credential       → allowed
assume role           → allowed
access production     → allowed
delete resource       → allowed

Nothing necessarily looks wrong. Now consider the sequence:

credential
    ↓
privilege escalation
    ↓
production access
    ↓
destruction

The sequence tells a different story — the final delete operation cannot be evaluated independently from what happened before it. Authorization for autonomous systems cannot always be a stateless function of the current request; the PDP needs to maintain security state across the session.

The implementation tracks things like accumulated privilege, sensitive-data exposure, action velocity, recent destructive activity, category sequences, and data lineage. The result is closer to a running security state than a traditional one-shot permission check.

Data flow makes the problem harder

The same principle applies to data.

Imagine an agent that sits between a private database and a public channel:

The agent might legitimately have READ PostgreSQL and SEND Slack. Neither permission is necessarily problematic by itself. The problem is the combination: if the agent reads restricted data from PostgreSQL and then attempts to publish it into a public Slack channel, the security violation emerges from the data flow across multiple actions, not from either action alone.

The PDP therefore needs to understand source classification, data lineage, and destination exposure together, rather than looking only at individual tool permissions. That allows a policy to express something closer to "restricted data must not cross into a public destination" — fundamentally different from simply asking whether the agent has permission to read the database or send a Slack message.

The architecture

Putting the pieces together:

The important architectural decision is that the enforcement surface is not the policy engine. The policy and PDP define and evaluate authorization once. The hook, MCP gateway, or eventually an endpoint/runtime sensor are simply different places from which normalized security facts enter the same decision system.

That separation is what makes the model potentially agent-agnostic. If the policy logic were coupled to Claude Code, adding another agent would mean rebuilding the authorization model. That isn't the architecture I want.

Policy Decision Records: making decisions reproducible

A security decision is much more useful when it can be explained and reproduced.

Every decision produces a Policy Decision Record containing information such as:

principal
agent
action
resource
facts
matched rules
reasons
session evidence
policy version
engine version
decision

That gives the system an important property: "why was this blocked?" has a deterministic answer. It also makes policy changes testable. Instead of change policy → deploy → hope, historical decisions can be replayed against a candidate policy:

historical PDRs
      ↓
candidate policy
      ↓
replay
      ↓
stricter / looser decisions
      ↓
review
      ↓
deploy

That turns policy changes into something that can be evaluated before enforcement rather than something that has to be discovered in production.

The adoption problem: security that talks too much gets removed

This might be the most important engineering lesson in the entire project.

The most interesting test wasn't a sophisticated simulated attack. It was 23 ordinary developer actions producing zero interruptions.

A security control that constantly interrupts normal work gets disabled by the people it was supposed to protect.

Security friction needs to be treated as an engineering metric.

The goal isn't to block as much as possible — it's to block what matters while remaining invisible for normal work.

That means a good policy engine needs to understand the difference between rm -rf ./build and rm -rf / even though both are local destructive commands. The important context isn't just environment = local — it includes the scope of the resource being affected. That is why resource resolution became part of the enforcement path.

The persistent PDP

The first implementation was a per-request Python process. That worked, but the overhead was unnecessary.

Version 2.1 moved the hot path to a persistent PDP runtime:

Hook
  ↓
Unix socket
  ↓
Persistent PDP
  ↓
Decision
  ↓
PDR

The result: cold path 83 ms, warm path 47 ms. The improvement came from keeping the policy, session state, resource mappings, and engine loaded rather than rebuilding them for every request.

But the daemon isn't the important architectural change — it's a runtime optimization, not the policy plane. The policy semantics are identical with or without it. There's also graceful degradation: if the daemon isn't available, the hook falls back to the in-process decision path, with tests verifying that the two paths produce equivalent decisions. A performance optimization should never silently become a security dependency.

The next question: MCP

This is where it's tempting to immediately build an MCP gateway. I'm deliberately not treating it as the obvious next feature. MCP is the next experiment.

The question isn't "can we build an MCP security gateway?" — there are already systems working on that problem. The more interesting question is whether the same contextual authorization model can govern a remote MCP invocation without changing the policy semantics underneath it. That's a much stronger test of the architecture.

Suppose Claude Code currently produces:

delete production database
        ↓
PDP
        ↓
DENY

Can an MCP invocation representing the same logical operation produce:

delete production database
        ↓
MCP adapter
        ↓
same PDP
        ↓
DENY

without creating an MCP-specific policy language? If it can, the enforcement surface is genuinely separate from the policy plane. If it can't, we've discovered that the model wasn't as surface-neutral as we thought.

Measuring adaptability instead of claiming it

"Agent-agnostic" is easy to say. It is harder to demonstrate. So I want to measure it.

One proposed metric is a Policy Adaptability Ratio (PAR):

PAR =
scenarios whose policy survives unchanged
──────────────────────────────────────────
total scenarios

For example, if we take 100 authorization scenarios and 94 can move from the Claude Code hook to an MCP enforcement point without changing the policy model, PAR = 94%.

I'd also measure three narrower properties separately: policy portability (can the same policy execute on the new surface?), fact portability (can the new surface produce equivalent security facts?), and decision equivalence (does the same logical scenario produce the same authorization decision?). Together, these turn "surface-independent" from an architectural claim into something that can actually be measured — and potentially disproved.

Where this leaves the project

This isn't an attempt to build another AI firewall. The more interesting question is whether authorization itself needs to evolve.

We've spent decades asking whether a principal can perform an action. Autonomous agents force a harder question: should this agent perform this action, against this resource, with this data, in this environment, given everything that has happened in this session?

That changes what the authorization system needs to understand. Identity becomes one input. The action becomes one input. Resource sensitivity, data lineage, session history, runtime telemetry — each becomes one input. The authorization decision becomes the point where those signals meet:

Identity
                    │
Action ─────────────┤
Resource ───────────┤
Data ───────────────┤
Session ────────────┤
Environment ────────┤
Telemetry ──────────┤
                    ↓
             Contextual PDP
                    ↓
          ┌─────────┼─────────┐
          ↓         ↓         ↓
        ALLOW    ESCALATE    DENY

The goal isn't to predict whether an agent is malicious. It is to make the authorization decision contextual, explainable, reproducible, and enforceable.

The next experiment is deliberately narrow: move the same policy from the Claude Code hook to MCP and measure how much of the authorization model survives unchanged. If it does, the policy plane is genuinely portable. If it doesn't, we've found another boundary that needs to be solved. Either result is useful.

Because the real question isn't whether an agent can do something….

The engine, the hook, and the policy language described throughout this post are on GitHub: github.com/ihrishikesh0896/contextual-authorization. It's still very much in development — the reasoning model, the trust boundary, and the daemon are all there and tested, but the next thing I'm exploring is blast radius: not just whether a single action should be allowed, but how far its consequences could actually reach once it is. Right now the model reasons about a request and a session; it doesn't yet reason about downstream impact.