Agent guards for Microsoft Agent Framework
This example gives an AI agent two tools. getClientRecord returns account data
that includes personally identifiable information (PII). Arcjet guards
sendEmail, so the model can read the record but cannot send its sensitive
fields outside the application. Arcjet checks the model-selected recipient and
generated message body before the email provider runs.
What is Arcjet?
Arcjet is the AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.Quick start
Section titled “Quick start”This guide shows you how to guard an agent tool in your
1. Configure the policy
Section titled “1. Configure the policy”The policy decides what the guarded tool is allowed to do. Create it once, in the Arcjet Console, before you run the example.
You don’t have to write the conditions. Describe what the action refuses and
Arcjet writes them for you. Go to Policies, choose to guard an action in
your own application rather than a coding agent, enter email.sent as the
Guard label, and paste this description:
Deny when recipient is not in the allowed_recipients list.Deny when body contains a bank account number or a routing number.Arcjet opens a draft in the visual builder with three declared inputs, a
sensitive information detector over body, and two rules in dry run. Review
it, set both rules live, add a test asserting that an address on the allow list
fires no rule and one asserting that an address outside it is denied, then
publish. A live rule can’t publish without at least one stored test.
Have your coding agent configure it
Connect the Arcjet MCP server and give your agent this prompt. It reads and writes the same policies the Console does, so nothing about the result differs.
Create an Arcjet guard policy with the label email.sent.
Declare recipient as a SERVER string, allowed_recipients as a SERVER stringlist, and body as a LOCAL string. Deny when recipient is not a member ofallowed_recipients. Deny when body contains a BANK_ACCOUNT or ROUTING_NUMBERentity.
Add one test for a recipient on the allow list and one for an address outsideit, then publish. Show me the guard() call that sends these inputs.Ask it for the guard call as well as the policy. A policy does nothing until the application sends values under exactly the names it declares, and a mistyped name produces a policy that silently never fires.
Configure it by hand
Declare these inputs:
| Input | Type | Exposure | Required |
|---|---|---|---|
recipient | String | SERVER | Yes |
allowed_recipients | String list | SERVER | Yes |
body | String | LOCAL | Yes |
Declare one detector, body_scan, for sensitive information over body,
denying the BANK_ACCOUNT and ROUTING_NUMBER entity types.
Then declare two live rules:
| Rule | Kind | Decided by |
|---|---|---|
external-recipient | EXPRESSION | The policy’s Rego |
sensitive-body | DETECTOR | body_scan, directly |
external-recipient needs one condition. In the visual builder, add a
condition requiring recipient to be a member of allowed_recipients. The
builder generates this Rego:
package arcjet.guard
import rego.v1
deny contains "external-recipient" if { not input.values.recipient in input.values.allowed_recipients}sensitive-body needs no expression. A detector rule is decided by its
detector, which is what keeps the email body private: the SDK runs the
detection and Arcjet receives the verdict, never the text.
The examples configure the on-device Rampart backend, which detects these
entity types locally, so the body never leaves your application. The first
guarded call loads the Rampart model, so it takes noticeably longer than the
ones after it.
Publish the policy before you run the example. Until you do, the label matches
nothing and every guard call comes back NOT_CONFIGURED, which falls through
to the SDK rule decision rather than denying. For the other statuses, see
Testing and reference.
Framework wrappers take action for the same slug you configured as the policy
label. Direct guard() calls use the field name label.
2. Install Arcjet
Section titled “2. Install Arcjet”In your project root, install the SDK:
The Microsoft Agent Framework helpers are a nested Go module. It requires Go 1.26, the framework’s own floor, while the root SDK requires Go 1.25.
go get github.com/arcjet/arcjet-go@latestgo get github.com/arcjet/arcjet-go/agentframework@latestgo get github.com/arcjet/arcjet-go/sensitiveinfo/rampart@latestgo get github.com/microsoft/agent-framework-go@latestMicrosoft Agent Framework for Go is a public preview, so the helpers stay at
v0.x while its API settles. There is no version segment in the import path;
agentframework/go.mod names the framework version it is built against, which
Go treats as a lower bound.
3. Set your key
Section titled “3. Set your key”Create a free Arcjet account and follow the instructions to add a site and get a key.
Set your environment variables:
# Export your Arcjet API key from https://console.arcjet.comARCJET_KEY=ajkey_yourkeyARCJET_ENV=developmentAn empty Key on the Guard client reads ARCJET_KEY from the environment.
4. Wrap the tool
Section titled “4. Wrap the tool”Wrap the send-email tool so Arcjet evaluates the remote policy before the email provider runs.
Guarding one tool only helps if it is the only way to reach the capability. If the same agent session also exposes an unguarded path to sending mail – an MCP server it inherited, a built-in tool, a second tool you didn’t wrap – the model can take that path instead. Keep the session’s tool surface to what you wrapped.
This adapter accepts inputs and actor. The sample maps policy fields from
trusted application state and the model-selected arguments.
Create one client, then wrap the tool with GuardTool so every call is
evaluated before the tool runs:
package main
import ( "context" "encoding/json" "errors" "fmt" "os"
"github.com/anthropics/anthropic-sdk-go" "github.com/microsoft/agent-framework-go/agent" "github.com/microsoft/agent-framework-go/provider/anthropicprovider" "github.com/microsoft/agent-framework-go/tool" "github.com/microsoft/agent-framework-go/tool/functool"
"github.com/arcjet/arcjet-go" "github.com/arcjet/arcjet-go/agentframework" "github.com/arcjet/arcjet-go/sensitiveinfo/rampart")
// User is the authenticated caller. The allow list is application state, so a// model that proposes a recipient cannot widen it.type User struct { ID string AllowedRecipients []string Record map[string]string}
type userKey struct{}
// WithUser puts the authenticated user on the context. The policy resolvers// below read it from there rather than from the model's arguments.func WithUser(ctx context.Context, u *User) context.Context { return context.WithValue(ctx, userKey{}, u)}
func userFrom(ctx context.Context) (*User, error) { u, ok := ctx.Value(userKey{}).(*User) if !ok { // A resolver error counts as policy that was not evaluated, so this // fails closed rather than sending the call without an actor. return nil, errors.New("no authenticated user on the context") } return u, nil}
type emailArgs struct { Recipient string `json:"recipient"` Body string `json:"body"`}
// Rampart detects BANK_ACCOUNT and ROUTING_NUMBER on this machine, so the// email body is never sent to Arcjet, which receives the verdict only. That// covers the guard call; it says nothing about what your own tools hand the// model.var backend = must(rampart.New(rampart.Options{}))
var guard = must(arcjet.NewGuardClient(arcjet.GuardConfig{ SensitiveInfoBackend: backend,}))
// This tool returns real account details so the third scenario has something// for the policy to catch. Its result goes to the model provider like any tool// result: Rampart keeps the guard's inspection of the email body on this// machine, which is a different thing from keeping the record out of the// prompt. Return only what the model needs, and put a guard on the tool that// reads it if that set is itself sensitive.var getClientRecord = functool.MustNew( functool.Config{ Name: "get_client_record", Description: "Get the account details on file for the current customer", }, func(ctx context.Context, _ struct{}) (map[string]string, error) { u, err := userFrom(ctx) if err != nil { return nil, err } return u.Record, nil },)
var sendEmail = functool.MustNew( functool.Config{Name: "send_email", Description: "Send an email"}, func(_ context.Context, in emailArgs) (string, error) { // Your mail transport goes here. return fmt.Sprintf("sent to %s", in.Recipient), nil },)
// The action matches the guard label you published in step 1, and the three// inputs match the names that policy declares.var guardedSendEmail = agentframework.MustGuardTool(guard, sendEmail, agentframework.ToolPolicy{ Action: "email.sent", Actor: func(ctx context.Context, _ json.RawMessage) (string, error) { u, err := userFrom(ctx) if err != nil { return "", err } return u.ID, nil }, Inputs: func(ctx context.Context, raw json.RawMessage) (map[string]arcjet.GuardPolicyInput, error) { u, err := userFrom(ctx) if err != nil { return nil, err } var in emailArgs if err := json.Unmarshal(raw, &in); err != nil { return nil, err } return map[string]arcjet.GuardPolicyInput{ "recipient": arcjet.GuardPolicyServerString(in.Recipient), "allowed_recipients": arcjet.GuardPolicyServerStringList(u.AllowedRecipients), "body": arcjet.GuardPolicyLocalString(in.Body), }, nil }, Metadata: arcjet.SecurityMetadata{Destination: "email", Reversibility: "irreversible"}.Metadata(),})
var emailAgent = anthropicprovider.NewAgent(anthropic.NewClient(), anthropicprovider.AgentConfig{ Model: os.Getenv("ANTHROPIC_MODEL"), Instructions: "You send email only to approved recipients. You never ask a " + "follow-up question, and you quote any account details you retrieve " + "exactly as returned, without masking them.", Config: agent.Config{ Tools: []tool.Tool{getClientRecord, guardedSendEmail}, },})
// RunEmailAgent runs one turn for a user. The correlation ID groups every// decision from this turn into one Sequence in the Arcjet console.func RunEmailAgent(ctx context.Context, u *User, prompt string) (string, error) { ctx = WithUser(ctx, u) ctx = arcjet.ContextWithCorrelationID(ctx, "turn_"+u.ID)
resp, err := emailAgent.RunText(ctx, prompt).Collect() if err != nil { return "", err } return resp.String(), nil}
func must[T any](v T, err error) T { if err != nil { panic(err) } return v}5. Try the policy
Section titled “5. Try the policy”Keep identity, allowed recipients, and sensitive records on the server. The browser sends only the scenario name.
Expose a small server endpoint. The browser sends only the scenario name:
package main
import ( "context" "encoding/json" "log" "net/http")
var user = &User{ ID: "customer-123", AllowedRecipients: []string{"approved@example.com"}, Record: map[string]string{ "name": "Alex Morgan", "bank_account": "0123456789", "routing_number": "022000020", },}
var scenarios = map[string]string{ "allowed": "Send the message 'Your report is ready' to approved@example.com.", "blocked": "Send the message 'Your report is ready' to outside@example.net.", "pii": "Email the account details you have on file to approved@example.com.",}
type agentRequest struct { Scenario string `json:"scenario"`}
func handleAgent(w http.ResponseWriter, r *http.Request) { var req agentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } prompt, ok := scenarios[req.Scenario] if !ok { http.Error(w, "unknown scenario", http.StatusBadRequest) return }
output, err := RunEmailAgent(r.Context(), user, prompt) if err != nil { // Keep the detail in the log. A run error can carry provider and // guard internals, which the browser has no use for. log.Printf("agent run: %v", err) http.Error(w, "the agent could not complete this run", http.StatusInternalServerError) return }
w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"output": output})}
func main() { defer func() { _ = guard.Close(context.Background()) }()
mux := http.NewServeMux() mux.HandleFunc("POST /api/agent", handleAgent) // Serve the demo page from the next step out of public/ at the site root. mux.Handle("/", http.FileServer(http.Dir("public")))
log.Println("listening on :8080") log.Fatal(http.ListenAndServe(":8080", mux))}Add a minimal page that calls the endpoint and displays the agent’s response. Serve it however your framework serves static files:
<h1>Agent Guard policy demo</h1><p>Test recipient and sensitive-information policies on the same email tool.</p>
<button data-scenario="allowed">Allowed recipient</button><button data-scenario="blocked">Blocked recipient</button><button data-scenario="pii">Sensitive information</button><pre id="output">Choose a scenario.</pre>
<script> // Send only the selected scenario name to the server. const output = document.querySelector("#output");
for (const button of document.querySelectorAll("button")) { button.addEventListener("click", async () => { output.textContent = "Running agent…"; const response = await fetch("/api/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scenario: button.dataset.scenario }), }); const result = await response.json(); output.textContent = result.output ?? result.error; }); }</script>Each scenario demonstrates a different result from the same guarded tool:
- Allowed recipient: The recipient is on the allow list and the body has no
sensitive data, so
sendEmailreaches the email provider. - Blocked recipient: The model calls the same tool with an external
address, and the policy’s
external-recipientrule denies the call before the provider runs. The model receives the denial result and explains it. - Sensitive information: The recipient is allowed, but the agent first
calls
getClientRecordand receives test bank account and routing numbers. When the model puts that tool result in the email body, the policy’ssensitive-bodyrule deniessendEmailbefore the data leaves the application. The detector runs in the SDK, so the body is never sent to Arcjet.
Read the decision, not the absence of an email
Section titled “Read the decision, not the absence of an email”The samples give the agent a system prompt for a reason. Without one the
model asks a clarifying question, or masks the account numbers itself,
instead of calling sendEmail with them. Nothing is sent, no guard call is
made, and no decision is returned.
That is the outcome to watch for, because it looks like the guard worked. A reader with a broken policy, a wrong label, or a missing key sees “nothing was sent” and concludes the guard is enforcing. Check the decision in the Arcjet dashboard or in your logs. No decision means the model declined, not that the guard denied.
The same distinction is the reason to guard the tool at all. An unguarded agent often refuses the sensitive-information scenario on its own, because sending bank details over email looks wrong to the model. That refusal is judgement: it is non-deterministic, and a different prompt can talk the model out of it. The blocked-recipient scenario is the deterministic contrast, since nothing about an external address looks unsafe to the model.
What next?
Section titled “What next?”Get help
Section titled “Get help”Need help with anything? Email support@arcjet.com to get support from our engineering team.