How We Write Security Agents
Cynative Research
What we learned writing 45 security agents and running them against live AWS, Azure, GCP, Kubernetes and GitHub estates.
In Cynative, our open-source AI-native cybersecurity platform, an agent is a markdown file: a one-line description and a prompt that asks one question about your infrastructure, like “which IAM principals can reach admin, and through which calls?” or “which Actions workflows run untrusted code with credentials?”. We’ve written 45 of them and run them against live AWS, Azure, GCP, Kubernetes and GitHub estates. Most of what we learned applies to any security agent you write, in Cynative or anywhere else.
Ask one question
Scope an agent to one question on one platform. A question that spans three clouds sounds coherent, but the platforms end up sharing one stop condition, and a failure on the first can keep the other two from ever reporting. We had exactly that agent: every read worked and it never produced a report. Split per platform, all three did.
Name the agent after the risk it looks for. aws-metadata-theft tells you what you’ll learn; a name like aws-ec2 would only tell you what it reads. If you keep a set of agents, don’t let one name sound broader than its neighbours. People pick from a list, and the one that sounds like the umbrella gets picked by mistake.
Then scope to what your credential can actually read. Cynative authorizes every call against a read-only policy before a credential is attached (SecurityAudit on AWS, roles/viewer on GCP, Reader on Azure, the cluster’s view role on Kubernetes), and a read above that ceiling comes back as a page of unresolved fields that looks like a report. For every read the policy denies, find a permitted call that carries the same fact, or cut it and write down what the agent can’t see and why.
Write the method
Name an outcome and leave the method open, and the model picks the method. Told to “scan every attached and inline policy”, one of our agents chose the single call that returns all of them, including every managed policy AWS publishes: nine megabytes, then fourteen more, for an account with three users, and no report. A filter on the same call answered the question in under 25 KB.
So most of a good agent is method: how the platform itself decides the answer. aws-public-storage is a good reference. Its first sentence is the question, and the rest explains how S3 resolves access: four inputs (account-level and bucket-level Block Public Access, the ACL, the bucket policy), Object Ownership read first because BucketOwnerEnforced makes a stored ACL meaningless, and which policy conditions actually narrow a Principal: "*" grant. aws:PrincipalOrgID under StringEquals with a literal value does; o-* under StringLike doesn’t. Left to work that out alone, a model will often see the key name somewhere in the policy and call the bucket private.
Be careful what you name, because naming a call invites it. We once told an agent to list three directories instead of fetching three files inside them, and named the files. It fetched them 36 times a run. Dropping the file names didn’t help; it rebuilt the paths from the directory names. What worked was removing the reason to look: the agent now resolves that file only where a rule requires reviewing it. If you catch yourself forbidding a call, remove the reason the agent wants it instead.
Don’t restate in prose what your runtime already enforces, either. Our agents no longer say “do not write”, because the action gate blocks writes regardless, and a second copy of a rule in a prompt drifts from the first.
Say what “nothing” means
“Nothing found” can mean three things: the objects are absent, they’re present and clean, or they were never read. A zero can’t tell an empty account from a working control from a hole in the report, and the hole looks the most like good news.
Every one of our agents carries two paragraphs about this. The first says a denied, unreachable, partial or empty read isn’t a clean result, so the agent names the resource and the field it couldn’t read and marks it unresolved. The second says that when nothing meets the question, the report’s first sentence says which of the three answers it is. Adding that second paragraph, word for word, took clean reports that open with a usable verdict from 14% to 68%. Copy both into yours.
Stop early, and honestly
Most estates are clean for most questions, and you shouldn’t pay full price to prove it. Write the agent in two stages: the cheapest enumeration that settles the question, an explicit “Stop here if” nothing resolves as exposed, and only then the expensive work of ranking findings and tracing what each one reaches.
The stop condition has to be one a well-run environment can meet. If it depends on a setting good estates often leave off, your best-managed accounts never stop early, and the expensive stage runs hardest where there’s least to find. Report that setting as a number instead.
Then check what the stop branch reports. Anything read in stage one and mentioned only in stage two disappears from every clean run. We had an agent that read whether clusters accepted plaintext connections and, on clean runs, reported only their auth mode. Every field the agent reads should appear in the stop condition or in the report it writes when it stops.
Require evidence for “intentional”
A useful agent says when an exposure is on purpose, or the finding that matters gets buried in a list. “Intentional” is also the label that sends a finding to the bottom of the queue, so it needs evidence the agent actually read. End the clause the way ours do: name the evidence, and a resource with no such evidence isn’t intentional.
Be specific about what counts. A role that trusts a CI provider tells you who vouches for the caller, and nothing about which callers get in. aws-privilege-escalation only accepts a trust condition that names the identities, such as a repository and ref on the CI token. If the only evidence you have is an absence, name what makes it meaningful: “no detection in this region” says something when another region has detection, and nothing when the whole account has none.
Give it the call shapes
Models guess API paths, and some paths are hard to guess. In our runs, one guessed path answered zero times in nineteen tries while the documented one answered 39 of 52. Every built-in agent ends with an appendix, “Call shapes a run has proven”, listing the host, path and headers that worked. Keep these out of the method and add one whenever a run shows the model getting a call wrong.
Write for the verifier
Before Cynative reports a finding, a verifier tries to refute it from the evidence attached to it. Refuted findings are left out, and ones it can’t confirm are marked low-confidence. So write the agent to tie every finding to the read that proves it: the resource, the field and its value.
The verifier only sees findings, though. A clean result never reaches it, so an agent that misses something entirely gets no second opinion. The only check on that path is what the agent itself says about “nothing found”: that an unread object isn’t a clean one, and whether the objects were absent, clean or never read.
Test it
Run the agent against your cleanest account first; if it costs more there than against a messy one, the stop condition is wrong. For every “none” or “zero” in the report, find the read in that same run that could have established it, and check that the response contained the field and didn’t just return a 200. Run it against a weaker model now and then. A strong model fills gaps from its own knowledge, so a weak one shows you which instructions you never wrote down. And measure cost in model turns rather than API requests; for us, one turn costs roughly twenty requests’ worth.
Start from a built-in
An agent is a markdown file with one frontmatter key, description (one line, up to 256 bytes), and a body. Put it in ~/.cynative/agents/ and run it with cynative --agent <name>. The quickest start is a copy of a built-in close to your question:
mkdir -p ~/.cynative/agents cynative agents show aws-public-datastores > ~/.cynative/agents/my-version.md
The file only supplies text. Approvals, connector authorization and the read-only ceiling apply to every tool call whatever it says, so a bad agent wastes a run but can’t widen what your credentials allow.
Cynative is open-source cloud native security software: one binary that runs against your own model endpoint, read-only by construction.
All 45 built-in prompts are published in full: cynative.com/agents
Code and docs: github.com/cynative/cynative
If you write one for a question we don’t cover yet, we’d love to see it.
