Security engineering for AI agent connectors: nine failure classes
How StaffOS secures the external tools its AI agents call: nine failure classes, from SSRF and URL templates to secret scope, and the control for each.
An AI agent that calls external APIs can read private data, reads content nobody has vetted and can send data out. The software around the model decides which hosts a tool may reach, what each request carries, who may point a saved secret at a destination and what comes back to the model. This paper describes how StaffOS makes those decisions for the external tools our agents call: nine failure classes, the control we build for each, and the details in common HTTP software that decide whether a control holds.
Hostnames, secrets and records in the examples are fictional. Statements about third-party software cite its documentation or source.
Checklist
These twelve checks apply to any connector that sends requests to URLs its users configure. Each names an input to test it with and the section that explains it.
- Judge the URL the transport will send.
http://{x}127.0.0.1/adminmust be refused, not expanded to loopback by the HTTP client (section 3). - Refuse every address that is not globally reachable, from an explicit table.
100.100.100.200,64:ff9b::7f00:1and::ffff:127.0.0.1must all be refused (section 2). - Pin the checked addresses, keyed on the host exactly as written. A request to
api.example.test.must connect to the pinned address, not resolve again (section 2). - Turn off redirects and proxy settings from the environment. With
HTTPS_PROXYset, the request must still connect directly, and a 302 must come back to you unfollowed (section 2). - Give DNS a deadline you can enforce. A name server that never answers must not hold a worker past the tool's timeout (section 2).
- Write each value for where it sits.
Tan Ah Kowmust reach a JSON body asTan Ah Kow, a quotation mark in a value must not add a key, and a path argument of..must be refused (section 3). - Scope a saved secret by use as well as by owner. A member without admin rights must not be able to change the URL of a tool that sends a saved secret, or run it (section 4).
- Give automatic calls their own contract. A POST tool that no owner has attested as read-only must never be called automatically (section 7).
- Keep platform values out of the model's reach. A schema that declares a
phone_numberargument beside{{conversation:phone_number}}must be refused (section 8). - Replace transport error text, and redact known values in every spelling. A DNS failure must return a fixed sentence that names no host, and
{"echo":"sk\u002dlive\u002d4f9c"}must come back redacted (section 5). - Treat what cannot be read as unsafe. An unknown authentication mode, a credential the platform cannot decrypt and a JSON body nested past the decoder's limit must each refuse or be withheld (sections 4 and 5).
- Bound bytes as they arrive. An upstream that streams 100 MB must be cut off at the cap, with nothing past it buffered (section 9).
Four worked examples
Each example below defeats a guard that looks correct. We reproduced all four locally with curl 8.7.1 and PHP 8.5.7.
1. Check whether your HTTP client expands URL templates
URL as configured: http://{x}127.0.0.1/admin
Host a parser sees: {x}127.0.0.1 (not an IP address, no DNS answer)
URL Laravel sends: http://127.0.0.1/admin
A guard that treats a host with no DNS answer as harmless approves this URL. Laravel's HTTP client then passes it through Guzzle's RFC 6570 expander, {x} has no value and expands to nothing, and the request goes to loopback. Section 3 explains the expansion and how to leave the expander nothing to do.
2. Key the DNS pin on the host exactly as the request spells it
# The pin applies: cURL connects to 203.0.113.10 without a DNS lookup.
curl --resolve api.example.test:443:203.0.113.10 https://api.example.test/
# The fully qualified name is a different key, so cURL ignores the pin and resolves again.
curl --resolve api.example.test:443:203.0.113.10 https://api.example.test./
A guard that strips the trailing dot before it builds the pin key has checked one DNS answer and left the connection to another. Section 2 covers pinning.
3. Decode before you redact
{ "echo": "sk\u002dlive\u002d4f9c" }
A byte search for the known key sk-live-4f9c finds nothing in this body, and a JSON decoder returns exactly that key. Section 5 lists the other spellings a known value can take.
4. Refuse a path value that climbs the path
Template as configured: https://api.example.test/v1/customers/{id}/orders
Argument from the model: id = ..
URL after encoding: https://api.example.test/v1/customers/../orders
Path cURL sends: /v1/orders
rawurlencode('..') returns .., and libcurl removes dot segments before it sends a request, as RFC 3986 §5.2.4 describes and CURLOPT_PATH_AS_IS documents. A model steered by a customer message could move a call up the operator's API, carrying the tool's own credentials. Section 3 describes the refusal.
1. The threat model for agent connectors
A connector gives an AI agent a way to act outside the platform. On StaffOS, a workspace can give its agents two kinds of external tool: HTTP API tools it defines itself, and remote tool endpoints hosted by another service. Both run inside live customer conversations, so the threat model covers the customer, the model, the upstream service and everyone who configures the tool.
An HTTP API tool describes one request: a method, a URL template, headers, an authentication mode, an optional body and a JSON Schema for the arguments the model supplies. A template can include values the model provides, such as an order number, and values the platform fills, such as the current conversation's phone number or a saved workspace secret. A remote tool endpoint names one function and its schema, and the platform forwards the model's arguments to the endpoint's URL. It does not use the Model Context Protocol.
Simon Willison describes the combination of private data, untrusted content and external communication as the lethal trifecta for AI agents. Connectors bring all three together, and each control in this paper narrows one leg:
| Leg | What the agent holds | Controls that narrow it |
|---|---|---|
| Private data | Customer details, and whatever a tool returns | Platform values the model cannot supply (section 8); workspace and record scope (sections 6 and 8); redaction of the personal values a tool sent (section 5) |
| Untrusted content | Customer messages and upstream responses | Results labelled as data and bounded in size (sections 7 and 9); records rendered whole (section 10) |
| External communication | Tools that send requests, some carrying credentials | Destination control (section 2); request construction (section 3); secrets scoped by use (section 4); owner-gated automation (sections 6 and 7) |
Actors
| Actor | Trusted to | Not trusted to |
|---|---|---|
| Customer | Converse with the agent | Choose where a tool sends data, or see another customer's records |
| Model | Choose tool calls from the catalogue it was given | Supply a value the platform fills, or stand in for an owner's decision |
| Upstream API or remote endpoint | Return data | Keep responses small and prompt, keep credentials out of echoes, or instruct the model |
| DNS for a workspace-chosen host | Nothing | Answer consistently or promptly |
| Workspace member without admin rights | Edit ordinary tools | Decide where a saved secret goes, or switch on calls nobody chooses |
| Workspace owner or admin | Choose secrets, destinations and automation | Reach loopback, private or link-local addresses, or read platform credentials |
| Another workspace | Nothing | Learn anything about this one |
Assets
- Platform credentials, such as the keys the platform uses with its messaging partners.
- Workspace secrets that tools send to upstream APIs.
- Customer personal data: phone numbers, usernames, email addresses, external identifiers and custom fields flagged as personal.
- The internal network: loopback, RFC 1918 private ranges, link-local addresses and the cloud metadata service.
- Capacity: worker time and memory, the model's context window and the provider's limits on tools per request.
- The accuracy of what agents tell customers.
- Owners' decisions: when an owner switches a tool off or chooses where a saved secret goes, that decision must hold.
The nine failure classes
| # | Failure class | Boundary | Section |
|---|---|---|---|
| 1 | Outbound destination control | Rendered request to network | 2 |
| 2 | Request construction | Workspace template to rendered request | 3 |
| 3 | Secret scope | Configuration to request | 4 |
| 4 | Containment of errors and responses | Response and exception to model and logs | 5 |
| 5 | Authorization by effect | Settings request to stored tool | 6 |
| 6 | Automatic calls | Automation to dispatch | 7 |
| 7 | Model, workspace and platform values | Model arguments to request | 8 |
| 8 | Resource bounds | Network to worker; catalogue to provider | 9 |
| 9 | Output integrity | Response to the model's answer | 10 |
2. Outbound destination control
Server-side request forgery (SSRF) in a connector means a workspace-configured URL reaching an address it should not: loopback, a private network or the cloud metadata service at 169.254.169.254. A URL check prevents it only when it judges the same host, address, proxy and redirect policy the HTTP transport will use, so ours checks the transport's own inputs.
Host spellings a strict parser does not recognise
PHP's FILTER_VALIDATE_IP rejects 2130706433, 0x7f000001, 0177.0.0.1 and 127.1, so a guard built on it treats them as hostnames. DNS returns nothing for them, and libcurl connects to all four as 127.0.0.1. A guard that allows a name with an empty DNS answer therefore fails open. Percent-encoded and zone-identifier spellings behave the same way: parse_url() returns %31%32%37.0.0.1 and [::1%25lo0] as they are, and cURL normalises both to loopback addresses.
Our destination check accepts a host only when it is a plain DNS hostname or an IP address in canonical form, and refuses at dispatch any hostname that does not resolve. It runs on the URL after every value, the model's included, has been written in. The address check refuses, as whole blocks, every range that the IANA IPv4 and IPv6 special-purpose address registries mark as not globally reachable, and multicast. That covers loopback, 0.0.0.0/8, the RFC 1918 private ranges, the shared address space 100.64.0.0/10 that carrier-grade NAT and overlay networks use, link-local addresses including the cloud metadata address, and the documentation and benchmarking ranges. IPv6 is held to global unicast, 2000::/3, minus the documentation, 6to4 and Teredo blocks inside it. IPv4-mapped, IPv4-compatible, 6to4 and Teredo addresses are refused outright, and an address in the well-known NAT64 prefix 64:ff9b::/96 is judged by the IPv4 address it carries.
The policy is an explicit table of ranges, because PHP's range flags pass addresses that are not public. FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE accepts 100.64.0.1, 198.18.0.1, 192.0.2.1, 224.0.0.1, the NAT64 form of loopback 64:ff9b::7f00:1 and the 6to4 form 2002:7f00:1::1 on each PHP version we tested (8.1, 8.2 and 8.5), and on 8.1 and 8.2 it also accepts ::ffff:127.0.0.1. The shared address space it accepts holds a cloud metadata service: Alibaba Cloud's answers at 100.100.100.200. The same table of special-purpose addresses drives the policy's unit tests, the save-time checks and the dispatch-time checks.
The checked address and the connected address
A guard that resolves a name and then lets the HTTP client resolve it again has approved one DNS answer and connected to another. A DNS-rebinding zone can return a public address for the check and a private one for the connection, a time-of-check to time-of-use gap (CWE-367). We pin the approved address to the connection with cURL's CURLOPT_RESOLVE, so the socket opens to the address the guard checked.
Two details decide whether the pin holds:
- The pin key must match the request's host exactly. cURL matches
CURLOPT_RESOLVEentries on the host string, so a pin forapi.example.testdoes not apply to a request for the fully qualifiedapi.example.test.with its trailing dot. - A proxy is a second resolver. Through a proxy, cURL sends
CONNECT host:portand the proxy resolves the name, so the pin no longer decides the destination.
A redirect target is a second URL the guard never saw. All four HTTP clients below follow redirects by default, and three of them read proxy settings from the environment:
| HTTP client | Proxy from the environment by default | Follows redirects by default |
|---|---|---|
| PHP, Guzzle 7 | Yes: HTTPS_PROXY and NO_PROXY, and HTTP_PROXY in command-line processes |
Yes, up to 5 |
| Python, requests | Yes: http_proxy, https_proxy, no_proxy, all_proxy and their uppercase forms |
Yes, for every method except HEAD |
Go, net/http defaults |
Yes: HTTP_PROXY, HTTPS_PROXY and NO_PROXY |
Yes, up to 10 |
Node.js, fetch |
Only with NODE_USE_ENV_PROXY=1, available from Node 24.0 and 22.21 |
Yes |
Connector requests therefore run with redirects off, proxy settings from the environment ignored and the checked address pinned. In Guzzle, those are three request options:
// $host is the request's host exactly as written, trailing dot included.
$options = [
'allow_redirects' => false,
'proxy' => '', // an empty proxy overrides HTTP_PROXY and HTTPS_PROXY
'curl' => [CURLOPT_RESOLVE => ["{$host}:{$port}:{$checkedAddress}"]],
];
CURLOPT_PROXY documents that an empty proxy string disables the proxy even when an environment variable sets one. The OWASP SSRF Prevention Cheat Sheet recommends the same approach: validate the destination, resolve and check its addresses, and disable redirects.
The host the guard reads is the host cURL connects to
A guard that extracts the host with one parser while the transport uses another can approve one host and connect to another. The classic payloads hide the real host behind \@, #@, ?@, a second @ or an encoded slash in the userinfo, as in https://allowed.example.test#@127.0.0.1/. Run through our guard and the transport together, each of these payloads is either refused or connects to the host the guard checked. Every address in the DNS answer is range-checked, and the connection is pinned to that checked set.
DNS needs a deadline
PHP's dns_get_record() takes no timeout, so a slow authoritative server for a workspace-chosen domain can hold a worker for as long as it likes. Timing the lookup after it returns measures the delay without bounding it. Our connectors resolve names in a separate process that is stopped at a deadline inside the tool's time budget, and a lookup that cannot finish is refused in the same way as one that fails.
A document parser is a network client
PhpWord loads images by default, and for a VML picture whose relationship is marked external it loads the image from the URL the document names. An uploaded Word file can therefore make the server fetch any address it chooses. Our document reader extracts text, which never needs images, so it loads none.
Both tool types get the same guard
The HTTP API tool and the remote endpoint proxy apply the same destination check and the same transport options. The proxy also accepts only absolute http and https URLs that name a host. The audit record of a manual test run stores the tool's identifier rather than its URL.
3. Request construction
A request that passes a check can still change before it is sent. Some HTTP clients treat every URL as a template, a header and a JSON body each have their own grammar, and query-string parsers read parameter names differently. We write every value for the position it occupies, in one pass, and refuse anything a later stage would reinterpret.
HTTP clients that expand URL templates
Laravel's HTTP client supports URI templates, and its send() method passes every URL through Guzzle's RFC 6570 expander, whether or not the caller bound any template parameters. Under RFC 6570 §3.2.1, an expression with no value expands to nothing:
Template as configured: https://api.example.test/members/{member_id}
Sent when unfilled: https://api.example.test/members/
The second URL requests the whole collection. Operator expressions such as {?member_id}, {/member_id} and {+member_id} expand to nothing in the same way. Reserved expansion ({+var}) and fragment expansion ({#var}) pass /, ?, # and @ through unencoded, so a braced span can change a URL's path, query or host. An expression with no value can move the host too, as the first worked example shows. A check that recognises only {name} passes all of these.
Guzzle's expander returns a string that contains no brace unchanged. Our renderer relies on that. It writes every value itself, appends arguments that no placeholder used to the query of a GET or DELETE, and stops the call before anything is sent if any { or } remains. The error says whether a declared argument is missing, an undeclared one was used or the template itself is invalid.
One renderer, one pass
One renderer writes every template a tool sends (URL, headers, body and credentials) for every path that sends it: the model's own calls, the automatic pre-fetch described in section 7 and manual test runs. It writes the model's arguments and the platform's values in the same pass, and a value it has written is never read again. A written value therefore cannot complete a placeholder, and an argument cannot turn {{secret:{key}}} into a reference to a secret of the model's choosing.
Values written for where they sit
| Where the value sits | How it is written | Refused |
|---|---|---|
| URL | Percent-encoded, saved secrets included | An undeclared placeholder, even when the call passes it; a missing or empty value; a value that forms a . or .. path segment |
| Header | As written | An argument holding a line break, a control character other than tab, or non-ASCII text; an undeclared placeholder; a missing or empty value |
| Body, inside a JSON string | As JSON string content, escaped only where JSON requires | A declared argument the call leaves out |
| Body, at a value position | As the JSON value the model sent: number, boolean, null, array or object | A declared argument the call leaves out |
Percent-encoding leaves . alone, so a value can still form a dot segment in a URL path, which libcurl removes before sending (the fourth worked example). The renderer tracks which characters of the path came from a value and refuses a . or .. segment that a value forms, whether the value came from the model, the platform or a saved secret. A dot segment the operator wrote into the template is sent as written: it is the operator's own path on the operator's own host.
Percent-encoding a body value makes the upstream read the encoded text literally: Tan Ah Kow arrives as Tan%20Ah%20Kow, and a lookup by that name finds nothing. Writing raw text into a body lets a quotation mark add keys. Platform values in a body must sit inside a JSON string, where a quotation mark in a contact's name is escaped; the optional values described below are the one exception. A body that is not valid JSON once every value is written in, or that puts a value in an object key, is refused when the tool is saved and again when it runs. An argument that JSON cannot represent, such as a model's 1e999, which decodes to infinity, refuses the call on every surface. In a body, a brace the schema does not declare is sent as written, since a GraphQL selection set is written in braces.
Parameter names that mean the same thing
Model arguments that no placeholder uses are appended to the query, so the renderer must stop one from colliding with a parameter the operator fixed in the template, such as a tenant filter. PHP's parse_str() cannot make that comparison: it converts dots and spaces in names to underscores and nests bracketed names, so tenant.id reads as tenant_id and filter[tenant_id] reads as filter. We compare names by one identity that merges every reading: cut at the first [, fold . and space to _, and ignore case. A model argument whose identity matches a parameter already in the rendered URL is dropped, and the operator's value stands.
Save time and run time agree
Every placeholder in a tool's URL or sent headers must be declared in its argument schema and marked required before the tool can be saved, and every declared placeholder in the body must be required. At run time, a call that lacks one is refused before anything is sent. An optional placeholder would otherwise disappear from the request, which would then address a different resource from the one the operator described.
The headers a request carries
Which headers a call sends is decided once, from the tool's configuration, before any value is resolved: the tool's own headers, the authentication mode's Authorization, one Content-Type when the method sends a body, one Accept, and the transport's own, such as Host. The checks that decide whether a call may go out (a missing value, the secret fence in section 4, the pre-fetch contract in section 7) read that same decision. A test fails if the application's shared HTTP client gains any default option or middleware, because a tenant tool would carry it to a host the tenant chose.
Optional platform values
Some lookups accept one of several identifiers, such as a phone number or a username. A platform value marked optional can sit only at a JSON value position in a body. It is written as a JSON string when present and as null when missing, and it is refused in a URL, a header, a credential, inside quotation marks and as an object key. A secret is never optional. A call is refused when every optional value is missing and no other conversation, contact or custom-field value reaches the request, so a lookup never goes out with nothing that identifies the customer.
4. Secret scope
A saved secret is safe only when two questions have the same answer: who may manage it, and who may decide where it is sent. StaffOS keeps each workspace's saved secrets in its own encrypted store, resolves a secret reference only from the tool's own workspace, and limits every change that could redirect a saved secret to the people allowed to manage it.
One scoped store
A secret reference in a tool resolves from the tool's own workspace store and nowhere else. A reference to a secret the workspace has not saved stops the call, and nothing is sent.
Managing a secret and using it are separate permissions
Limiting who can create secrets leaves a second path open. A member who cannot read a saved secret could edit a tool that sends it, point the tool at a host they control and run a test. That is Norm Hardy's confused deputy: the platform holds the authority, and a party who should not wield it chooses how it is used.
For a tool that sends a saved secret, only a workspace owner or admin can:
- change its method, URL, headers, authentication or body;
- run it manually from the settings page;
- switch it back on after it has been disabled.
We gate only what a lower role cannot reproduce another way. Manual test runs of tools that send no saved secret are open to every role, because a member who can already edit or copy such a tool gains nothing from the gate.
Why one pass matters for secrets
A request template can mix saved secrets, conversation values, contact values and the model's arguments. Resolving them in separate passes lets one value inject another. If secrets are substituted first and the result is scanned for conversation placeholders, a secret whose text happens to contain a conversation placeholder pulls the customer's phone number into the request. Reversing the order moves the same defect to conversation values that contain secret-shaped text. The single pass in section 3 reads only the operator's source text, so no substituted value is ever parsed as a placeholder.
Refuse what cannot be resolved
A reference that matches no pattern, such as a misspelled or unterminated placeholder, must not travel as literal text in a header, body or credential. A loose outer pattern finds every candidate span, a strict inner pattern validates it, and any span left unresolved refuses the call.
Credentials fail closed as well. A secret that resolves to nothing usable, an unsaved secret in a bearer token or an unknown authentication mode stops the call before it is sent. The alternative is an unauthenticated request, and an upstream that accepts anonymous writes would run the side effect.
Credentials the platform cannot decrypt
A credential encrypted under a key the platform no longer holds cannot be read, and nothing can check what it would send. A tool whose credential cannot be decrypted drops out of the agent's catalogue, and a saved secret that cannot be decrypted resolves to nothing, which refuses the call. For the owner-or-admin fence, such a tool counts as sending a saved secret. An ordinary save keeps the stored ciphertext, so a restored key can still read it; only a value someone enters, or an explicit choice to delete it, replaces it. Where a read shows a placeholder in place of a stored credential, a write refuses that placeholder as a value, so a read-modify-write cannot replace a real credential with its mask.
A credential stored for one purpose
A credential kept for one purpose must not be selectable for another. The remote endpoint proxy can authenticate with a credential the workspace has stored, and it refuses an authentication setting that names one of the workspace's AI model providers before it reads any credential. No tool configuration can send a stored model key to a host its configurer chose.
Secrets in logs
Some OAuth code exchanges put the client secret in the query string. Meta documents its WhatsApp Embedded Signup token exchange as a GET to /oauth/access_token with client_secret among the query parameters, and HTTP clients commonly include the full URL in a transport error. PHP's exception string also includes the message of every previous exception in the chain, and that string is what error logs and failed-job records keep. Our Meta API calls that carry a credential in the query string rethrow a transport failure with every query string removed and without the original exception attached.
5. Containment of errors and responses
Error messages and response bodies are the two routes by which a credential leaves a tool call. For transport failures, we return a classification and a fixed sentence and discard the library's text. For the response of either tool, we redact the credentials it sent in every spelling JSON allows, and an HTTP API tool also redacts the personal values it sent.
Replace error text instead of scrubbing it
HTTP libraries quote the inputs that caused an error. libcurl repeats the hostname in messages such as Could not resolve host, so a credential placed in a hostname survives any scrubbing of the URL. Guzzle's header validation throws an exception that quotes the invalid header value. A scrubber has to anticipate every place a credential can sit (query parameters under any name and casing, repeated and bracketed keys, userinfo, path segments, hostnames, numbers) and every library that might quote it.
We do not scrub. A request that fails in transport returns one of a small set of classifications and a fixed sentence containing nothing derived from the request or the exception. The only code that reads the exception decides two things: whether the failure was a timeout, using cURL's error number where one exists, and whether the response was too large.
Enforce containment statically
An architecture test parses every tool that sends HTTP requests and requires each call that reaches the network to sit inside the body of a try block whose handlers catch and contain connection failures. It works on the syntax tree, resolves imported and aliased names, follows HTTP clients passed in through dependency injection, and treats any code shape it cannot read as a violation. A new tool with an unguarded request fails the test suite.
The handler must catch the right class. In Laravel, ConnectionException and RequestException are siblings under HttpClientException, so a handler for RequestException never sees a DNS failure or a timeout.
Redact a known value in every spelling
When a response has to reach the model, the question changes from "what might be a credential?" to "where does this known credential appear?". The HTTP API tool supplies the values it treats as credentials: saved secrets, the credentials in its authentication settings, and the values of headers whose names mark them as credentials, such as Authorization. It adds the customer's personal values it sent, described below, in each spelling it sent them, the body's JSON spelling included. The remote endpoint proxy supplies its own set: its stored credential, the values of credential-named headers, the Cookie header and the long cookie values in it, and the parts of its URL that can carry a key, each as written and as decoded. Both tools pass their set to the same redactor, which finds each value in every spelling the response can use.
| Spelling | Why a byte match misses it | Handling |
|---|---|---|
| JSON escapes | RFC 8259 §7 lets any character be escaped, so sk\u002dlive does not contain sk-live |
Decode the body, redact the decoded values, re-encode |
| Large integers | PHP decodes an integer above PHP_INT_MAX as a float unless JSON_BIGINT_AS_STRING is set, losing the digits a match compares |
Decode large integers as strings |
| Floats | PHP's string cast of a float prints 14 significant digits by default, so 1234567890123456 becomes 1.2345678901235E+15 |
Compare numbers by canonical value |
| Object keys | An upstream can echo a credential as a key | Check keys as well as values |
| Basic authentication | The header carries base64 of user:password, and an upstream that parsed it returns the plain password |
Treat the encoded token and the decoded password as known values |
| Overlapping values | str_replace() with arrays can rewrite text that an earlier replacement inserted, corrupting the marker |
Use strtr(), which tries the longest key first and never rescans replaced text |
| Deep nesting | A JSON body nested past the decoder's depth limit cannot be decoded, and falling back to byte matching would reopen the escape bypass | Return a fixed sentence |
Redaction must not corrupt data. Substring matching of numbers would replace unrelated values such as a count of 10, and a one-character credential could rewrite the text a timeout classifier reads. Numbers are matched by exact value, and timeouts are classified from cURL's error number.
Personal data in tool results
When an HTTP API tool sends a customer's email address, external identifier or custom fields flagged as personal data to an upstream, those values are redacted from the response before it reaches the model's context and the stored trace. The customer's name and record identifier are left alone, because results repeat them for ordinary reasons and redacting a short value damages every occurrence of it.
6. Authorization by effect
A permission attached to a form field can miss the effect the field has. We list what a switch's safety depends on (destination, payload, method), fence changes to those inputs while the switch is on, gate turning the object on and gate on-demand use. We validate the row the save will keep, which can differ from the request that arrived.
Gate the effect
The pre-fetch setting described in section 7 turns a tool into a call nobody chooses. For a POST tool, pre-fetch also needs an attestation that the endpoint only reads. Only a workspace owner or admin can turn on either setting, and anyone can turn them off. Gating the settings alone would leave a gap, since a member could leave them untouched and retarget the tool underneath them. While a tool is automatic, the owner-or-admin fence also covers its request fields. Switching a disabled automatic tool back on is gated for the same reason: its stored pre-fetch setting would resume automatic calls the moment it is enabled.
Record visibility is narrower than tenancy
A manual test run can take its values from a real conversation. Checking that the conversation belongs to the same workspace proves tenancy, while the inbox shows each user only the conversations they may see. The test endpoint resolves the conversation through the same visibility scope as the inbox, so a user cannot name a colleague's hidden conversation to borrow its customer's details.
Validate the row the save will keep
An update that omits a field keeps the stored value. Validation that reads only the request therefore judges a row that will never exist. Laravel runs no rule, custom rules included, on an attribute that is absent or an empty string unless the rule is implicit. Consider a cross-field rule that lets only an admin retarget a tool that sends a secret. If the rule reads the request, a sparse update that sends a new URL and omits the credential moves the stored credential to a new host.
Before validation runs, our update path restores every stored field that a cross-field rule reads or is attached to, so the rules judge the row as it will be saved. Every path that saves a tool validates through the same rules.
7. Automatic calls
Context pre-fetch calls selected tools before the model's turn, so the agent starts with facts it would otherwise look up or guess. The model's tool policy allows some live writes because the model chose that call in context. A pre-fetch runs on every inbound message and again when a job retries, so a write would repeat without anyone deciding it should. Pre-fetch therefore has its own eligibility rule, applied when a tool is saved and again before every call.
A tool qualifies for pre-fetch only when all of the following hold:
- It is a GET, or a POST that a workspace owner or admin has attested only reads.
- Its argument schema is closed: no properties, no pattern properties, nothing required and
additionalPropertiesset tofalse. - No argument placeholder appears in its URL, its sent headers or its body.
The attested POST exists for read APIs that take personal data. Converted to a GET, a customer's phone number would travel in the URL, where gateways and access logs keep it.
Attestation makes a POST eligible without lowering its risk level, so the platform's other gates still apply. The call is refused in a sandbox that has not enabled external writes, in evaluation runs and while a support ticket is open on the conversation.
Pre-fetch works from one snapshot of the turn. Its candidates are copies of the tool rows in the catalogue the model received for that turn. Each is checked against the workspace and passes through the same gate as a model call, so an edit made mid-turn cannot turn a catalogued GET into a POST. Each turn has a cap on the number of tools, a deadline for each call and for the whole step, and a byte budget for the context; once the budget is spent, no further calls are made.
Results reach the model inside delimiters and are labelled as data. The controls that matter do not depend on the model honouring that label: the turn's catalogue, the slots a model can fill and every value the platform supplies are fixed by configuration the model cannot edit.
8. Model, workspace and platform values
A model chooses arguments. The platform fills values such as the current customer's phone number. The two sources must never overlap, because a prompt-injected model would then choose whose data a lookup uses.
- Schema collisions. A tool schema whose argument shares a name with a conversation or contact field the platform fills is refused at save time and again when the tool is built for a turn. A saved secret is never read from an argument, so no schema can stand in for one.
- Workspace scope. A contact's values, and the phone number or username on a conversation's channel identity, are read only when they belong to the conversation's workspace.
- Identity values carry their platform. A username is unique only within its own messaging app, so a tool receives the conversation's username only when the identity is on a platform where the handle identifies the sender. When a sender drops a username, the identity clears it, since someone else may claim it later. A late-processed older message cannot restore it: the identity keeps the username from its newest message, compared inside one database update.
- Reserved names. A workspace tool or remote endpoint named like a platform tool would shadow it or be dropped. Every tool name the platform can offer in a customer conversation is reserved, and a census test fails when a new platform tool is missing from the list. Name patterns end in
\z, because in PCRE$also matches before a final newline.
Provider schema rules are part of the contract
A model provider's schema rules decide whether a request is accepted at all. An empty JSON object decodes to an empty PHP array, and an object with numeric keys decodes to a list. Re-encoded, such a schema reaches the provider with an array where an object is required. A provider that enforces the type, as Gemini's function declarations do, rejects the whole request, and every conversation turn that includes the tool fails. We validate remote endpoint schemas with a walker that knows what each JSON Schema keyword's value holds.
9. Resource bounds
Each resource needs a bound where it is consumed. A size check after the body has arrived, a DNS lookup with no deadline and a timeout of zero each allow unbounded work.
| Resource | Failure mode | Control |
|---|---|---|
| Response bytes | A size check after the client returns leaves the transfer unbounded: Guzzle's cURL handler has already written the whole body to a temporary stream | A response sink refuses writes past a fixed cap as the bytes arrive; cURL's own size limit stays as a second bound |
| Time | In Guzzle a timeout of 0 means wait indefinitely, and it is the default | A tool's timeout must be between 1 and 120 seconds, and one below a second is refused at run time |
| DNS | dns_get_record() has no timeout |
Resolution in a process stopped at a deadline inside the call's budget |
| Tool catalogue | Every enabled tool is described to every customer-facing agent on every turn, and providers cap function declarations per request | At most 32 workspace API tools in a turn's catalogue, and 16,384 characters per schema |
The catalogue cap is sized against the most conservative published provider limit. OpenAI's API reference stated a maximum of 128 functions for Chat Completions tools through at least April 2025, and Google's function-calling reference gives 128 function declarations per request. With 32 workspace tools, the largest StaffOS agent's full catalogue stays below 128.
10. Output integrity
Confidentiality controls do not cover every harm a tool can cause. When an API returns a list of records with parallel fields, a model can pair one record's name with another record's label and tell a customer something false. Our response rendering can collapse each record into one line of text before the model sees it, so a record's fields reach the model already joined.
Line rendering fails toward the original data. A record missing a referenced field is kept whole, and a template that does not parse leaves the payload untouched. Rendering refuses to rewrite an object at the configured path as a list, and the path and the template are saved together or not at all.
11. How we build and verify security controls
Changes to these boundaries are built and checked as follows.
- Adversarial review. An automated reviewing agent, instructed to break the change, reviews each revision and usually supplies a reproduction with each finding. The author reproduces each claim before changing code.
- Mutation-checked guards. Each new guard is removed or disabled in a throwaway copy of the code, and the change counts only if a test then fails. A test that passes without its guard is rewritten until it cannot. The method is set out in DeMillo, Lipton and Sayward's 1978 paper on test data selection.
- Contract tables over examples. Each contract is a table of input classes, and each row asserts the exact bytes sent or that nothing was sent:
- special-purpose addresses, one table shared by the address policy's unit tests and the save-time and dispatch-time checks;
- URL shapes, run through the model's calls and the pre-fetch;
- body and header placements, run through the model's calls and every manual test path;
- headers the connector never sends, crossed with the texts that would refuse a call if rendered;
- credential containment, crossing both tool types with every place a credential can sit and every kind of failure;
- saves, across every path that saves a tool, its starting states and its input shapes.
- One implementation per rule. A rule checked at save time and at run time is one function called twice. Two derivations of the same predicate drift apart.
- Fail closed on what cannot be read. Unknown authentication modes, credentials that cannot be decrypted, hosts that do not resolve, JSON bodies nested past the decoder's limit and code shapes the static guard cannot parse all refuse.
12. Mapping to public taxonomies
The mapping is ours, made for orientation. It avoids CWE-200 and CWE-269, which MITRE discourages for vulnerability mapping.
| Failure class | CWE | OWASP Top 10 for LLM Applications 2025 | OWASP API Security Top 10 2023 |
|---|---|---|---|
| Outbound destination control | CWE-918 Server-Side Request Forgery; CWE-367 Time-of-check Time-of-use Race Condition, for DNS rebinding | API7:2023 Server Side Request Forgery | |
| Request construction | CWE-180 Incorrect Behavior Order: Validate Before Canonicalize; CWE-116 Improper Encoding or Escaping of Output; CWE-235 Improper Handling of Extra Parameters | LLM05:2025 Improper Output Handling | |
| Secret scope | CWE-441 Unintended Proxy or Intermediary ("Confused Deputy"); CWE-863 Incorrect Authorization; CWE-532 Insertion of Sensitive Information into Log File | LLM02:2025 Sensitive Information Disclosure | |
| Containment | CWE-209 Generation of Error Message Containing Sensitive Information; CWE-532 | LLM02:2025 | API10:2023 Unsafe Consumption of APIs |
| Authorization by effect | CWE-863; CWE-639 Authorization Bypass Through User-Controlled Key, for record visibility | API1:2023 Broken Object Level Authorization; API3:2023 Broken Object Property Level Authorization; API5:2023 Broken Function Level Authorization | |
| Automatic calls | CWE-863; CWE-367, for re-reading configuration mid-turn | LLM06:2025 Excessive Agency | |
| Model and platform values | CWE-639, for lookup keys a model could otherwise choose; CWE-441; CWE-116; CWE-625 Permissive Regular Expression | LLM01:2025 Prompt Injection; LLM05:2025 | API1:2023 Broken Object Level Authorization |
| Resource bounds | CWE-770 Allocation of Resources Without Limits or Throttling | LLM10:2025 Unbounded Consumption | API4:2023 Unrestricted Resource Consumption |
| Output integrity | LLM09:2025 Misinformation |
For teams building on MCP, the Model Context Protocol's security best practices cover related attacks in that protocol's own setting, including confused-deputy attacks, token passthrough and SSRF.
Appendix: implementation notes
Details for teams building the same controls.
Test runs and input normalization
Laravel trims string input and converts empty strings to null in its default middleware. The manual test route is exempt for argument values, so a test run renders exactly the bytes a model's call would.
Flashed form input
When a settings form fails validation, the input flashed back to the session leaves out the credential fields of both tool types: an HTTP API tool's URL, headers, authentication and body, saved secret values, access tokens, and the URL, headers and authentication of each remote endpoint in a list. The fields are removed after whatever flashed the input, so a list keyed by name or a controller that flashes input itself is covered too.
Version tokens
Tool rows that hold credentials carry a version token for optimistic locking. An unsalted digest of a row would let anyone holding the token test guesses at the credentials in that row offline, so the token is computed over the row together with a server secret. It is also computed with JSON_THROW_ON_ERROR: json_encode() returns false on invalid UTF-8, and without the flag every such row would share one token and pass every staleness check.
Measuring a schema once
The schema limit is measured one way at save time and at run time. JSON encoding without JSON_UNESCAPED_UNICODE stores each non-ASCII character as a six-character \uXXXX escape, so measuring the stored text would count a Chinese-language schema roughly six times over, and measuring the submitted text would count the settings form's indentation. One function serves both checks: it measures the schema written compactly, with every character counted as itself.
Replacing a list
A write that supplies a list of remote endpoints replaces the stored list whole. array_replace_recursive() merges lists by index, so a merge would keep entries the owner removed, with their stored credentials.
The declared length of a 304
RFC 9110 §8.6 lets a 304 carry the Content-Length a 200 would have had, although the 304 holds no body. The response size check measures the body received and never reads the declared length.
Author and citation
Vin Lim is CTO of StaffOS. He works on agent orchestration, business integrations and the security of the tools AI agents call.
Suggested citation: Lim, V. (2026). Security engineering for AI agent connectors: nine failure classes. StaffOS technical paper, version 1.0, September 24. https://staffos.xyz/blog/ai-agent-connector-security
For how we structure and evaluate the agents that use these tools, see our paper on agent harness engineering.
Frequently asked questions
Why are AI agent tool calls a security risk? +
An agent that calls external APIs can read private data, reads content nobody has vetted and can send data out. Simon Willison calls that combination the lethal trifecta. Unless the software around the model controls destinations, request contents and responses, a manipulated prompt or a misconfigured tool can move data where it should not go.
How do you prevent SSRF when customers configure their own API URLs? +
Accept only plain hostnames and canonical IP addresses. Refuse every range the IANA special-purpose registries mark as not globally reachable, including loopback, private, shared and link-local space and the cloud metadata address, using an explicit table of ranges. Resolve the name within a deadline and pin the checked address to the connection so a second DNS answer cannot change it. Turn off redirects and proxy settings inherited from the environment.
Why is checking a URL before sending it not enough? +
The URL a guard inspects may differ from the URL that is sent. Some HTTP clients expand every URL as an RFC 6570 template after the application builds it, cURL connects to host spellings that strict parsers do not recognise as addresses, and a proxy resolves names itself. A check holds only when it judges the same input the transport will use.
How should model arguments be written into a JSON request body? +
As JSON, for the position each one occupies. Inside a JSON string, write the argument as string content, escaped only where JSON requires. At a value position, write the JSON value the model sent. Percent-encoding makes the upstream read the encoded text literally, and pasting raw text lets a quotation mark add keys. Refuse a body that is not valid JSON once every value is written in.
Can a model argument change which API endpoint a tool calls? +
It should not. Percent-encode every argument written into a URL, refuse any template syntax left over, and refuse a value that forms a . or .. path segment. libcurl removes dot segments before it sends a request, so a template such as /customers/{id}/orders with an id of .. would reach /orders on the operator's host with the tool's credentials.
How should an AI agent platform scope API secrets? +
Keep each workspace's saved secrets in its own encrypted store and resolve references only from that store. Then scope saved secrets by use: only the people allowed to manage a secret should be able to change where a request carrying it goes, test that request or switch on automatic calls that send it.
How do you keep credentials out of what the model sees? +
Replace transport error text with a classification and a fixed sentence, because HTTP library errors can quote URLs, hostnames and header values. For a tool's response, redact the credentials the tool sent in every spelling JSON allows, and withhold a JSON body that is nested too deeply to decode.
Can the model choose which customer a tool looks up? +
Only through arguments the tool's schema declares. The platform fills the conversation's phone number and the contact's details, and a schema that declares an argument with one of those names is refused when the tool is saved and again when it is built for a turn. Saved secrets are never read from arguments at all.
References
- [1] Willison, S. The lethal trifecta for AI agents: private data, untrusted content, and external communication. 16 June 2025.
- [2] OWASP Cheat Sheet Series. Server-Side Request Forgery Prevention Cheat Sheet.
- [3] IANA. IPv4 Special-Purpose Address Registry.
- [4] IANA. IPv6 Special-Purpose Address Registry.
- [5] Alibaba Cloud documentation. View instance metadata (metadata service at 100.100.100.200).
- [6] OWASP. Top 10 for LLM Applications 2025.
- [7] OWASP. API Security Top 10 2023.
- [8] Model Context Protocol. Security Best Practices.
- [9] IETF RFC 6570. URI Template. Sections 3.2.1, 3.2.3 and 3.2.4.
- [10] IETF RFC 3986. Uniform Resource Identifier: Generic Syntax. Section 5.2.4, Remove Dot Segments.
- [11] IETF RFC 8259. The JSON Data Interchange Format. Section 7.
- [12] IETF RFC 9110. HTTP Semantics. Section 8.6, Content-Length.
- [13] Laravel framework source. PendingRequest::send() expands every URL through UriTemplate::expand().
- [14] Laravel documentation. HTTP Client, URI Templates.
- [15] Laravel documentation. Requests, Input Trimming and Normalization.
- [16] Laravel documentation. Validation, Implicit Rules.
- [17] Guzzle uri-template source. UriTemplate::expand().
- [18] Guzzle source. Client reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment.
- [19] Guzzle documentation. Request options: allow_redirects and timeout.
- [20] Requests documentation. Proxies.
- [21] Requests documentation. Redirection and History.
- [22] Go standard library. net/http: DefaultTransport, ProxyFromEnvironment and Client.CheckRedirect.
- [23] Node.js documentation. NODE_USE_ENV_PROXY.
- [24] MDN Web Docs. RequestInit: redirect.
- [25] curl documentation. CURLOPT_RESOLVE.
- [26] curl documentation. CURLOPT_PATH_AS_IS.
- [27] curl documentation. CURLOPT_PROXY.
- [28] PHPWord source. Word2007 reader loads an external VML image from the URL the document names.
- [29] PHP manual. parse_str.
- [30] PHP manual. strtr.
- [31] PHP manual. PCRE anchors.
- [32] Meta for Developers. Onboarding business customers as a Tech Provider (Embedded Signup token exchange).
- [33] OpenAI openai-openapi specification, commit 498c71d. Chat Completions tools: "A max of 128 functions are supported."
- [34] Google Cloud. Function calling reference, limitations.
- [35] Hardy, N. The Confused Deputy (or why capabilities might have been invented). ACM SIGOPS Operating Systems Review 22(4), 1988.
- [36] DeMillo, R. A., Lipton, R. J., Sayward, F. G. Hints on Test Data Selection: Help for the Practicing Programmer. IEEE Computer 11(4), 1978.
- [37] MITRE. CWE-918: Server-Side Request Forgery (SSRF).
- [38] MITRE. CWE-639: Authorization Bypass Through User-Controlled Key.
About the author
Vin Lim
Co-founder / CTO, StaffOS
Vin Lim is CTO of StaffOS. His work covers agent orchestration, business integrations and the controls that govern what AI agents can reach, send and reveal.
Related reading
Agent harness engineering: improving AI without fine-tuning
How StaffOS engineers context, tools and execution controls with fixed model weights. Public research benchmarks, synthetic test cases and reproducible charts.
WhatsApp will charge for every message from October 1. The bill lands hardest on chatty automation.
From October 1, 2026, Meta bills every message a business sends on the WhatsApp API, replies in the 24-hour window included. The math, and how to build for it.
How Southeast Asian SMEs adopt AI, and where it pays off
Notes from Make AI Work, our workshop with UOB FinLab: how SMEs in Indonesia, Thailand and Malaysia use AI, what controlled studies show, and where to start.