When your patching agent reaches for a live payload: proving fixes safely with canaries
I asked an agent to fix vulnerabilities and prove each fix works. It reached for rm -rf / and a reverse shell to do the proving. This is the story of how I stopped it: a safety contract baked into the skill, plus a purpose-built canary service that lets a test demonstrate an exploit path without ever firing a real one.
Introductionđ
In my Deep Scan experiment (pairing deterministic scanners with an agentic triage-and-remediation loop) the scanning was never the scary part. The patching was.
Hereâs the trap. When you ask an agent not just to fix a vulnerability but to
prove the fix works, it needs a demonstration: show the sink was exploitable
before the patch and is inert after. And the most direct way to
demonstrate a command-injection sink is⊠to inject a command. Left to its own
judgement, the agent writes exactly the test a pentester would: feed the sink
; rm -rf /, point a proof-of-SSRF at some URL, exfiltrate a fake secret to a
domain it invented on the spot. You watch it draft that, and even in a sandbox it
is genuinely a little đ±.
The lesson I took away is narrow but important:
An agent that patches security must be constrained not just in what it fixes, but in how it is allowed to demonstrate the fix. Demonstration is a side-effecting act, and side effects need a contract.
This post is about the two controls I added to make that safe: a hard safety
contract encoded in the skill, and an inert canary service that gives the
agent a benign, owned target to prove capability against. Both live in the
open: the skill in the projectâs .claude/commands, the canary at
github.com/kubeek-sec/canary.
1. The problem: to prove a fix, the agent runs the exploitđ
Why a demonstration needs a payloadđ
A good security fix should ship with a reachable-before / contained-after test: something that would have tripped on the vulnerable code and passes on the fixed code. Thatâs what makes a patch reviewable: the reviewer sees the proof, not just a claim.
The problem is that for a whole class of vulnerabilities, the honest proof is the attack:
| Vulnerability class | The âobviousâ proof an agent proposes |
|---|---|
| Command injection | feed the sink ; rm -rf / or $(reboot) and watch it fire |
| SSRF | make the server fetch an attacker-controlled URL |
| Insecure download / RCE staging | download and stage a payload script, then run it |
| Data exfiltration | POST a captured secret to an external host |
Deserialization (e.g. yaml.load) | craft an object that spawns a process |
Every one of those, done literally, means running real exploit code and/or reaching a host you donât control. In a test suite. On a dev laptop. In CI.
What the agent actually proposedđ
Unprompted, the agentâs demonstrations were technically excellent and operationally terrifying: destructive shell payloads to prove a shell-injection sink, and outbound calls to arbitrary external hosts to prove an exfiltration path. It wasnât being reckless; it was being thorough. A real payload is the most convincing proof, so absent any rule, thatâs what it optimized for.
The core insight. The agent isnât wrong that a live payload proves the point. Itâs wrong to think proving the point is worth the blast radius. Thatâs a judgement about acceptable risk, exactly the kind of thing you cannot leave implicit and hope the model shares your risk tolerance.
2. Control #1: a safety contract in the skillđ
The first fix wasnât code, it was policy, encoded where the agent reads it.
The remediation workflow is a skill (a slash-command,
.claude/commands/security-patch.md), and I rewrote the top of it as a
non-negotiable contract the agent must honor on every patch.
Two things make it work: an explicit allowlist of the only side effects a demonstration may ever produce, and an explicit denylist of what is forbidden, in prose, before any procedure.
The allowlist, only two inert side effects are ever permitted:
1. Create a `<branch>.demo.txt` marker file whose contents are the branch name; or
2. Call ONE controlled kubeek.com canary endpoint via the canary helper,
and nothing else.
The denylist, quoted from the skill:
STRICTLY FORBIDDEN. Never write, run, or commit any of:
- real exploit payloads, reverse shells, or data/credential exfiltration to any host other than the
kubeek.comcanary endpoints;- destructive or state-changing commands:
rm/rmdir,mvor overwrite of real files,chmod,kill, dropping DB tables, writing outside the repo;- real secrets, or any change outside the current patch branchâs scope.
And the escape hatch that stops the agent âsolvingâ the constraint by getting creative:
If you cannot demonstrate something without a real exploit, describe it in prose instead. Do not implement the exploit.
Put the constraint in the skill, not in your prompt-of-the-day. A skill is versioned, reviewed, and loaded every single run. A one-off âplease be carefulâ in chat is not a control; itâs a wish. Moving the rule into the workflow file is what turns âthe agent behaved this timeâ into âthe agent is bounded every timeâ.
3. Control #2, the canary: an inert, owned targetđ
A contract tells the agent what it may do. But a marker file (<branch>.demo.txt)
only proves local behavior. A whole class of vulnerabilities is about the
network: SSRF, exfiltration, insecure download/âdropâ. To prove those are
contained you have to prove an outbound path was reachable and is now
closed, which means the demonstration genuinely needs to make a request leave
the machine.
So the missing piece was a benign, owned place for that request to go. I
built one: canary, a tiny PHP service
behind Cloudflare on a domain I control (kubeek.com). Its own README puts it
plainly:
âCanary is a project to host some php & files on a website to facilitate your security tests.â
It exposes a handful of inert endpoints. Each one stands in for a capability an exploit would abuse, against a target that does nothing harmful:
| Capability to prove | Canary endpoint | What it returns |
|---|---|---|
| Outbound HTTP reachability | GET|POST /info/ | {"status":"ok","method":"âŠ"} (echoes the method) |
| IP / info collection (exfil beacon) | GET /monip/ | the origin IP as seen across several headers |
| Insecure âdropâ (fetch-and-stage) | GET /run/download.php?file=run.sh | an inert script |
| Direct staged download | GET /run/run.sh | echo "It runs!" and nothing else |
That last row is the âmanaged dropped payloadâ idea. To prove a download/RCE-staging
sink, an exploit would fetch and stage a real payload. Instead the agent fetches
run.sh, whose entire body is echo "It runs!". The capability (I can reach
out, download, and stage a file) is demonstrated; the payload is neutered by
construction. The dangerous verb, none of the danger.
The helper is the enforcement pointđ
Documenting âonly call these endpointsâ isnât enough. The agent could still
hand-roll a curl to anywhere. So the endpoints ship with a canary helper
(canary.sh / canary.ps1 / canary.py, same CLI on every OS) that can only
reach the canary domain:
# Every call is on-domain and always sends a User-Agent, nothing else is reachable.
bash .claude/scripts/canary.sh info # GET /info/
bash .claude/scripts/canary.sh monip # GET /monip/ (IP-echo beacon)
bash .claude/scripts/canary.sh direct run.sh # GET /run/run.sh (inert "drop")
The skill tells the agent to prefer the helper precisely because it canât go off-domain; the tool shape enforces the policy the contract states. Belt and suspenders.
Watch out: two Cloudflare gotchas I hit building this, now baked into the helper so the agent never trips on them: Cloudflare 403s any request with no
User-Agent, so the helper always sends one; and/info301-redirects to/info/, and a followed 301 downgrades POSTâGET, which silently corrupts a method-echo test. The helper targets the trailing-slash form directly.
4. Putting it together: reachable-before, contained-afterđ
With both controls in place, a demonstration test for a network-capable sink becomes safe and honest. The pattern: assert the unfixed path would have reached a canary, and the fixed path no longer does.
# A non-regression test for an exfiltration / SSRF sink, using the canary helper.
import sys; sys.path.insert(0, ".claude/scripts")
import canary
# Capability probe: prove the outbound path is reachable at all (inert target).
status, _ = canary.monip()
assert status == 200
# ...then assert the FIXED code path never calls canary.*, e.g. the sanitizer
# strips the user-controlled URL, so no outbound request is ever made.
The epistemics are identical to running the real exploit (the vulnerable path could reach an external host; the patched path cannot), but the blast radius is zero. No destructive command, no attacker infrastructure, no real secret. Every byte that leaves the machine goes to an inert endpoint I own and monitor.
One review branch per fix, each with a demonstration test: here yaml.load() â yaml.safe_load() (CWE-502). The demonstration proves behavior using only the two allowed inert canaries, never a live payload.
And crucially, the agent never auto-merges. Each fix lands on its own branch, demonstration included, for a human to review. The canary and the contract make the demonstration safe; the review gate makes the decision human.
5. Why it takes all three (defense in depth)đ
No single control here is sufficient on its own, and thatâs the real takeaway. Prompt-injected code or a creative model could, in principle, try to route around any one of them. So theyâre layered:
| Layer | Stops | Fails open if⊠|
|---|---|---|
| Safety contract in the skill | the agent choosing a live payload | the model ignores instructions |
| Canary helper (on-domain only) | requests reaching arbitrary hosts | the agent hand-rolls its own client |
| Network-fenced sandbox (egress default-deny) | any request the above two missed | the fence is misconfigured |
| Human review of every branch | a bad demo shipping unnoticed | the reviewer rubber-stamps |
Each layerâs failure mode is caught by the next. The contract makes the right thing the default; the helper makes the wrong thing harder; the network fence (covered in the main Deep Scan post: non-root, read-only rootfs, default-deny egress, optionally gVisor) makes it impossible for anything the first two miss; and human review makes sure a demonstration is a demonstration, not an exploit in disguise.
Summaryđ
| Control | What it does | Where it lives |
|---|---|---|
| Safety contract | allowlist of 2 inert side effects + explicit denylist + âdescribe in prose, donât exploitâ | .claude/commands/security-patch.md |
| Canary service | inert, owned endpoints that stand in for exfil/drop/HTTP capability | kubeek-sec/canary, served at kubeek.com |
| Canary helper | one CLI, on-domain only, always sends a UA; tests canât reach anywhere else | .claude/scripts/canary.{sh,ps1,py} |
| Review gate | every fix is a branch, never an auto-merge | the /security-patch workflow |
The four principles Iâd carry to any agentic-remediation setup:
- Demonstration is a side effect: govern it as deliberately as you govern the fix itself.
- Encode the rule where the agent reads it: a skill file, versioned and loaded every run; not a hopeful sentence in chat.
- Give the agent a safe target: prove the capability against something
inert and owned (
echo "It runs!"), never the real thing. - Layer it: contract, tooling, network fence, human review; each catches the previous oneâs failure.
The scary payload wasnât a bug in the agent. It was a missing contract. Writing that contract down (and building the inert target that makes it livable) is what turns âan agent that can patch securityâ into âan agent Iâd let near a real repoâ.
The canary endpoints are deliberately boring: echo "It runs!", an IP echo, a
method beacon. Thatâs the point. The entire value is in what they prove (a path
exists, then doesnât), not in what they do. If you spin up your own, keep them
inert, keep them on a domain you monitor, and never point a demonstration at a
host you donât own.