Skip to content
Context Windowby Alex Janjic
Menu
contextwindow.us/posts/the-ai-operating-model-in-practice-part-1Article

The AI Operating Model in Practice - Enforce agent permissions before a tool runs - Part 1

An agent can propose a refund but it cannot grant itself permission. Check order access before a tool runs and repeat the check in the business service before a refund attempt.

AI systems
Source links stay with the relevant section
Two paths approach an order card. One stops at a permission check while another passes with an approval marker.

Enforce agent permissions before a tool runs

An AI agent can ask an application to run tools, such as looking up an order or issuing a refund. A tool is a function the application exposes to the agent. The agent's request does not grant permission. Before that function runs, the application must check who is signed in, which order they can access and whether the exact action is approved.

This article shows where to place that check and why it must also live in the refund service. We use fictional customer accounts and a proposed refund to show what gets blocked, what is allowed and what the local tests actually prove.

The refund is proposed, not approved

In this fictional example, a refund assistant proposes USD 42.00 for Blue order B-104. Its signed-in user, Ana, belongs to Green. The application must deny the read and the refund. The model's reason for the request does not change who owns the order.

Check access before running either agent tool. Repeat the check in the business service so a direct caller cannot bypass it. The agent proposes an action. Application policy decides whether it can happen.

Green and Blue are fictional tenants, meaning separate customer accounts. The order IDs and amounts are sample values. The local checks below use fixed test data, not a live payment provider.

Give the agent one tool that reads orders and one that requests a refund. A tool is a function the agent asks the application to call. A read needs a check too: order details can expose private customer data.

Keep the caller outside the tool arguments

The application host gets the user and tenant from its trusted login context. The model can propose an order ID, amount and currency. It cannot choose the user's identity or tenant. Pass that identity into both tools outside the proposed arguments.

In the source, RefundAgent.Create captures a host-provided Caller when it creates the two functions. AIFunctionFactory.Create exposes read_order and refund_order to the agent. Their delegates pass the captured Caller to RefundService.

For the allowed example, Ana can read Green order G-217. Green policy marks it refundable. A reviewer has approved Ana's refund of USD 42.00 for that order. The approval applies to this action, not every refund on G-217.

If the caller requests B-104, the service queries the order store under Green. It also checks the tenant and allowed reader on the returned order. It gives the same denial when an order is absent or unavailable. This avoids revealing Blue's order through the read result.

Where the checks happen

The two checks have different jobs. Microsoft Agent Framework middleware can inspect a proposed function call before the function runs. RefundAgent.Create installs that callback with AsBuilder().Use(...). The callback checks reads or calls RefundService.AuthorizeRefundAsync for refunds.

The service is the authoritative boundary: it checks access again during a tool call and during any direct call. The middleware improves the agent path. It is not a replacement for authorization in the service.

The application owns the policy and the order records. The framework provides function invocation and the point where a callback can intercept it. The framework does not decide which tenant or refund Ana may access.

Check the tool call and the service call

Trusted caller contextUser and tenant set by the application
Agent tool proposalOrder ID, amount and currency are requests
Pre-call tool checkStop an unauthorized tool call early
Refund service checkCheck order, policy and exact approval
Order and refund stateRead or attempt a write only after permission
  1. Trusted caller contextidentity stays in host contextAgent tool proposal
  2. Agent tool proposalproposed argumentsPre-call tool check
  3. Pre-call tool checkpermitted tool requestRefund service check
  4. Refund service checkauthorized read or write attemptOrder and refund state
  • Early check
  • Authoritative service decision
Read this diagram as text

The application creates trusted caller context with a user and tenant. The agent proposes a tool call with an order ID and optional refund amount and currency. A pre-call tool check can reject it before the function runs. A permitted call reaches the refund service, which checks order access and current policy. A write also needs a valid approval for the exact refund. Only then can the service read the order or attempt a refund.

The agent's proposal must pass an early tool check and an independent service check before an order read or refund attempt.

Approve one exact refund

The approval record names the tenant, actor, operation and order. It also stores the exact amount in minor units and the currency. A minor unit is the smallest currency unit used for the amount. In USD, 4200 cents means USD 42.00.

Approval has an expiry and a revocation flag. At the time of a refund request, RefundService.AuthorizeRefundAsync checks both. It also checks whether the current order policy still permits refunds. Approval from yesterday cannot override today's policy.

USD 45.00 does not match the approval for 4200 cents. EUR 42.00 does not match either. A different actor, tenant, order or operation also fails. Reading G-217 can still be allowed after its refund approval expires because reading and refunding have separate requirements.

The check uses an exact currency string comparison. The host and order system must agree on currency format before storing or comparing values. For this small sample, the fixed records use consistent values. Do not assume this comparison solves currency validation for a payment system.

Illustrative requests and their expected decisions. The local checks also exercise these paths with fixed data.
MeasureIllustrative requestRuleExpected effect
Ana reads Green order G-217Ana reads Green order G-217Tenant and reader matchReturn the order
Ana reads Blue order B-104Ana reads Blue order B-104Tenant does not matchDo not return Blue data
Ana refunds G-217 for USD 42.00Ana refunds G-217 for USD 42.00Policy and exact approval matchPermit one local refund attempt
Ana asks for USD 45.00 or EUR 42.00Ana asks for USD 45.00 or EUR 42.00Approved arguments differNo refund attempt
Ana uses expired or revoked approvalAna uses expired or revoked approvalApproval is not valid nowNo refund attempt

Put the main rule in the service

RefundService.ReadOrderAsync loads an order within Caller.TenantId and checks its tenant and reader. It rejects missing and inaccessible orders the same way. A direct caller gets no special route around this check.

RefundService.AuthorizeRefundAsync starts by calling the read rule. It then loads the current approval for this tenant, actor and order. The service checks the order policy, exact approved arguments, expiry and revocation before any write attempt.

RefundService.RefundAsync calls AuthorizeRefundAsync again before it calls IRefundWriter.AttemptAsync. This keeps the service in charge even if a request never passes through the agent. The store and writer remain application services, not built-in Agent Framework authorization.

The call to the writer is an attempt, not evidence that money reached the customer. The test writer records local state. A live payment system needs its own way to confirm the business result.

src/ContextWindow.AI.AgentPermissions/RefundService.cs
    public async Task AuthorizeRefundAsync(
        Caller caller, RefundRequest request, CancellationToken cancellationToken = default)
    {
        Order order = await ReadOrderAsync(caller, request.OrderId, cancellationToken);
        Approval? approval = await records.FindApprovalAsync(
            caller.TenantId, caller.UserId, request.OrderId, cancellationToken);


        if (!order.Refundable || request.MinorUnits <= 0 ||
            approval is null || approval.TenantId != caller.TenantId ||
            approval.ActorId != caller.UserId || approval.Operation != "refund" ||
            approval.OrderId != request.OrderId || approval.MinorUnits != request.MinorUnits ||
            approval.Currency != request.Currency || approval.Revoked ||
            approval.ExpiresAt <= clock.GetUtcNow())
        {
            throw new UnauthorizedAccessException("Refund not permitted.");
        }
    }


    /// <summary>Repeat the rule at the service boundary before a write attempt.</summary>
    public async Task RefundAsync(
        Caller caller, RefundRequest request, CancellationToken cancellationToken = default)
    {
        await AuthorizeRefundAsync(caller, request, cancellationToken);
        await writer.AttemptAsync(caller, request, cancellationToken);
    }
Refund service policy checks. Direct callers use this same boundary.

Stop the agent tool before it runs

The agent uses the pinned Microsoft.Agents.AI package in the .NET sample. ChatClientAgent has a reading tool and a writing tool. Its AsBuilder().Use(...) callback sees the proposed function name and arguments.

For read_order, the callback calls RefundService.ReadOrderAsync. For refund_order, it parses the amount and calls RefundService.AuthorizeRefundAsync. It rejects other function names. Only after a check passes does next(context, cancellationToken) allow the function to run.

The callback does not accept tenant or actor from tool arguments. Caller was captured when the application created the agent. The tool delegate still calls the service, so the rule runs again at the business boundary.

An early check and the later check may see different records if policy or approval changes between calls. The later service check must use current records. For strict control across a real payment write, the application also needs a durable plan for the gap between authorization and execution.

src/ContextWindow.AI.AgentPermissions/RefundAgent.cs
        return agent.AsBuilder().Use(async (inner, context, next, cancellationToken) =>
        {
            string orderId = Required(context, "orderId");


            if (context.Function.Name == "read_order")
            {
                await service.ReadOrderAsync(caller, orderId, cancellationToken);
            }
            else if (context.Function.Name == "refund_order")
            {
                string amount = Required(context, "minorUnits");


                if (!long.TryParse(amount, NumberStyles.Integer, CultureInfo.InvariantCulture, out long minorUnits))
                {
                    throw new UnauthorizedAccessException("Invalid refund amount.");
                }


                RefundRequest request = new(orderId, minorUnits, Required(context, "currency"));
                await service.AuthorizeRefundAsync(caller, request, cancellationToken);
            }
            else
            {
                throw new UnauthorizedAccessException("Unregistered tool.");
            }


            return await next(context, cancellationToken);
        }).Build();
Function-call guard before tool execution. The caller identity comes from the host.

What the local checks establish

The local sample uses a scripted chat client, fixed fictional orders and approvals and a fake writer. A scripted chat client returns predetermined tool requests. It is useful for forcing a particular path but says nothing about what a live model will propose.

In the scripted fixture, 10 of 10 Release tests passed.

The allowed test checks that the fake writer records the intended local refund state. That control matters: a rule that blocks every call would pass only negative tests. Denied cases check no write attempt for missing approval, changed amount or currency, expiry and revocation.

Cross-tenant cases check reads as well as refund attempts. Direct calls to the service exercise its own rule without going through the agent. The tests inspect fake writer calls and local records, not settlement at a payment provider.

Run the following commands from the root of the sample with the .NET SDK. The tests use fixed data and need no provider credentials. The project keeps its Agent Framework dependency pinned in its central package versions. Run the complete test project in Release mode after restore and build.

Restore, build and run the local checksContextWindow.AI.AgentPermissions
$ dotnet restore tests/ContextWindow.AI.AgentPermissions.Tests/ContextWindow.AI.AgentPermissions.Tests.csproj
$ dotnet build tests/ContextWindow.AI.AgentPermissions.Tests/ContextWindow.AI.AgentPermissions.Tests.csproj --configuration Release --no-restore
$ dotnet test tests/ContextWindow.AI.AgentPermissions.Tests/ContextWindow.AI.AgentPermissions.Tests.csproj --configuration Release --no-build
Commands for the sample's .NET test project. The scripted fixture passed 10 of 10 Release tests.

A permission decision is not a refund result

Keep four records distinct: what the agent proposed, what the policy allowed, whether a write was attempted and whether the order and payment records show completion. A request ID can link them. A successful tool return does not settle a payment.

A timeout leaves the downstream result uncertain. Do not assume a new attempt is safe because the approval still exists. Preventing duplicate refunds needs a stable operation ID and retry controls at the writer and payment boundary. This sample tests authorization, not recovery from an unknown outcome.

A service that records a refund attempt should keep an uncertain result pending until business records resolve it. It should also record denials for audit without leaking another tenant's order details. An audit record lets an owner inspect a decision later. It is not permission for that decision.

Decide who can approve before adding tools

The product owner should say which refund requests need human approval and who may give it. The refund service owner should choose the expiry, revocation rule and storage for attempts. Give the agent an accountable owner who knows its tool list and can review denied calls.

LangChain's The Agentic Operating Model describes tool access checks, limited credentials, human review and execution records in sections 5 to 7. Those control areas help explain why a refund needs checks. The tenant and approval rules here belong to this application, not to that paper.

A label such as 'read only' cannot grant access to another tenant's order. Revisit access when the agent gets a new tool or a tool gains a new action. Keep credentials limited to the work the service needs.

Start with the order read check and the service's exact refund rule. Add the early function-call check so bad proposals stop sooner. Keep an allowed test beside every denial and inspect the local state. That is how the rule can block B-104 without blocking an approved refund for G-217.

Source

The complete sample is available at github.com/alex-janjic/ContextWindow.AI.Agentpermissions.

Context Window dispatch

Practical AI engineering you can actually use

Working code, experiments, and production lessons. Published when there is something worth sending, never padded with AI news.

Confirmation is required. Read the privacy policy. Unsubscribe at any time.

The email edition is preparing to launch. No campaigns are being sent yet.