Every security engineer has seen a bug get reported, patched, written up in a postmortem, and then watched a similar bug show up six months later in a different module of the same product. Same root cause, same fix. Sometimes the same engineer writes both versions.
This happens for the obvious reason. Large products have the same primitives reimplemented in many places. Authorization checks, input parsing, identity resolution, and tenant boundaries get rewritten by different teams at different times, often without anyone realizing it. When one team learns a lesson the hard way, that lesson lives in a postmortem doc and the heads of whoever was on call. Once the fix ships, people forget about it and move on.
Traditional SAST does not catch these repeats. It does not know what your authorization code looks like, where your tenant boundaries are, or which internal abstractions your team keeps getting wrong. The bugs that actually hurt you sit one layer below anything an off-the-shelf ruleset can see.
In this article I want to show how you can use past incident reports to catch these repeats with specialized AI agents, and walk through how I used this approach to find five zero-days in OpenClaw.
Press enter or click to view image in full size
OpenClaw is a self-hosted gateway for AI agents. The operator installs it on their own machine, points it at a language model (Claude, GPT, or a local model via Ollama), and connects it to whichever chat platforms they want to message the agent from. It supports more than twenty channels in total, including Slack, Discord, Matrix, Microsoft Teams, Telegram, WhatsApp, iMessage, Signal, and Zalo. With over 375,000 GitHub stars as of late May 2026, it is one of the more widely adopted open source projects in the AI agent space.
Each channel comes with its own allowlist. The operator specifies which users on that platform are permitted to message the agent, and that allowlist is the entire security model. If you can get yourself onto it, you can steer a tool-enabled AI agent that the operator trusts to act on their behalf. Depending on what the agent is wired up to do, that can mean reading files, sending messages, running shell commands, or hitting internal APIs. The consequences of an allowlist bypass on OpenClaw are not “an attacker leaks some data.” They are “an attacker drives your agent.”
The Telegram channel extension had a public advisory filed against it (GHSA-mj5r-hh7j-4gxf) for resolving non-numeric allowlist entries through mutable Telegram display names. It got patched in the Telegram module, and the project moved on.
Press enter or click to view image in full size
The next section walks through how I built detectors from OpenClaw’s past advisories and ran them against the codebase. That workflow needs a runner. The runner I used is agentgg.
agentgg is an open source CLI for agentic SAST. Think Nuclei, but for AI agents instead of YAML templates. Agents are markdown files with YAML frontmatter and a prompt body. Every agent is a tool-enabled investigation (Read, Glob, Grep) that declares where to look; there are no separate execution modes anymore. The runner dispatches the agents across a target codebase, validates each finding, and produces GHSA-shaped reports.
A scan runs in three phases, each writing a durable artifact under a state directory so the steps are inspectable and resumable. Recon is a fast survey that writes a project brief (languages, frameworks, auth model, integrations) and feeds it into the later phases so agents start oriented. Preconditions gate each selected agent, deciding whether it is worth running on this repo at all. Then each queued agent runs over its file set in batches, an optional validation pass classifies each finding as confirmed, false-positive, out-of-scope, or uncertain, and an optional scoring pass attaches a CVSS 3.1 severity.
Out of the box, agentgg ships with over 100 pre-built agents covering common security vulnerabilities, anti-patterns, and codebase recon. Custom agents can be installed into the agent directory or passed inline at scan time, which is the path I used for the OpenClaw-specific detectors. The CLI is built for CI/CD and integrates with GitHub Actions, with a diff mode that scopes a scan to only the files changed in a pull request.
It is on npm as agentgg and on GitHub under agentgg-dev/agentgg.
The input was the Telegram advisory along with the other public CVEs filed against OpenClaw’s channel extensions. I fed them into an agent-creation flow I built that reads past advisories and writes one agent per recurring bug pattern it finds.
The flow returned twelve agents, each targeting a distinct anti-pattern specific to OpenClaw. Examples include openclaw-audit-allowlist-identity-hunter (mutable identifiers used at trust boundaries), openclaw-audit-exec-policy-bypass-hunter (execution policy bypasses), and openclaw-audit-trusted-event-ingress-hunter (trust placement on unverified inbound events). The full set is published at agentgg-dev/agentgg-agents/openclaw. These are not generic CWE patterns. They are OpenClaw-shaped detectors derived from OpenClaw-shaped bugs.
I ran all twelve agents against the OpenClaw repository.
Eleven returned zero findings. The bug shapes from the original advisories had stayed patched.
One agent returned findings: openclaw-audit-allowlist-identity-hunter. It flagged the same bug shape in five separate channel extensions: Slack, Discord, Matrix, Zalo, and Microsoft Teams.
The bug, simplified. Each of these channel extensions accepts an allowlist of users in human-readable form, for example "allowFrom": ["Alice"]. At gateway startup, the extension resolves those names against the platform's user directory and replaces the human-readable entry with a stable user ID. The runtime allowlist check then runs against the resolved ID. The problem is that "Alice" on Slack, Discord, Matrix, Zalo, and Teams is a mutable field. Any other user on the platform can set their display name, or their nickname in Discord's case, to "Alice" with no admin approval. On the next gateway restart, the resolution path binds the attacker's ID into the allowlist instead of the legitimate user's. The attacker can now message the agent. The legitimate user is silently rejected.
Same root cause, five platforms. Each channel extension was written separately by different contributors, and the lesson from the Telegram advisory never propagated to any of the others. All five findings have been acknowledged and patched by the OpenClaw maintainers.
Findings
OpenClaw already knows display names are unsafe. Several channels expose an opt-in flag, dangerouslyAllowNameMatching (read via isDangerousNameMatchingEnabled(account.config)), off by default. An operator who never sets it expects ID-only matching. The bug is that name matching still happens, because one code path does not consult the flag.
There are two layers. The runtime gate (isSenderAllowed, per message) was usually fine: it compared the stable ID. The startup resolution was not. It took the configured names, looked them up in a directory, and wrote the resulting IDs into account.config.allowFrom without ever checking the flag. That is where all five findings lived.
Join Medium for free to get updates from this writer.
The canonical shape, from the patched Zalo extension:
const friends = await listZaloFriends(profile);
const byName = buildNameIndex(friends, (friend) => friend.displayName);
const { additions, mapping } = resolveUserAllowlistEntries(allowFromEntries, byName);
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
account = { ...account, config: { ...account.config, allowFrom } };
summarizeMapping("zalouser users", mapping, unresolved, runtime);The index is keyed on the mutable friend.displayName. So an operator writes allowFrom: ["Alice"], an attacker sets their own display name to Alice, and on the next restart the resolver binds the attacker's stable ID (whichever the API returns first) into the allowlist. The attacker can now drive the agent; the real Alice is rejected. The only signal is one summarizeMapping log line. This is CWE-639.
Same root cause, five extensions. Zalo, shown above, is the client-side version: a name index built from the friend list, keyed on displayName. Slack is the server-directory version: users.list matched against name, displayName, or realName, resolved in monitor/provider.ts with a second, parallel per-channel users block that had to be fixed separately. The other three (Discord, Matrix, and MS Teams) followed the same pattern with different directory calls. Matrix was the worst of them: it had no opt-in flag wired at all, so name matching was never gated in the first place.
The Slack fix (commit b895c6d, PR #77898) makes it concrete. Before, the directory lookup ran for every entry with no flag check:
// before
const resolvedUsers = await resolveSlackUserAllowlist({ token: resolveToken, entries: allowEntries });
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers, { formatResolved: formatSlackUserResolved });
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
ctx.allowFrom = normalizeAllowList(allowFrom);
summarizeMapping("slack users", mapping, unresolved, runtime);After, stable IDs resolve locally with no lookup, and the name lookup runs only behind the flag:
// after (commit b895c6d)
const allowNameMatching = isDangerousNameMatchingEnabled(slackCfg);// resolveStableSlackUserAllowlistEntries accepts only <@U...>, slack:/user: prefixed, bare U.../W...
const stableResolvedUsers = resolveStableSlackUserAllowlistEntries(allowEntries);
if (stableResolvedUsers.length > 0) {
const { mapping, additions } = buildAllowlistResolutionSummary(stableResolvedUsers, { formatResolved: formatSlackUserResolved });
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
ctx.allowFrom = normalizeAllowList(allowFrom);
summarizeMapping("slack users", mapping, [], runtime);
}
if (allowNameMatching) {
const resolvedUsers = await resolveSlackUserAllowlist({ token: resolveToken, entries: allowEntries });
// merge resolved IDs into allowFrom, same as before
}
The per-channel users block got the same gate. Full diff: https://github.com/openclaw/openclaw/commit/b895c6d939feffbb1afb08ae88eace6565bba172
Detectors are plain markdown. Full file: agentgg-dev/agentgg-agents/openclaw/openclaw-audit-allowlist-identity-hunter.md[ref]. Its frontmatter now references the five advisories it found; the first revision cited only the Telegram seed.
---
slug: openclaw-audit-allowlist-identity-hunter
name: Allowlist Identity Audit — Hunter (OpenClaw chat-channel extensions)
description: Audits chat-channel extensions for allowlist-bypass bugs where inbound sender/group identity is matched against a mutable field (display name, username, handle, email, group title) without the operator's `dangerouslyAllowNameMatching` opt-in. An attacker who can rename themselves to an allowlisted value slips past the allowlist without the operator ever flipping a dangerous flag.
version: 0.1.0
author: agentgg
noiseTier: normal
precondition:
prompt: |
Run only if this codebase IS OpenClaw — the chat-channel automation
platform — or one of its first-party extensions/connectors. Skip any
project that merely depends on or integrates with OpenClaw. If the recon
brief doesn't clearly indicate an OpenClaw codebase, answer no.
where:
extensions: [ts, tsx, js, jsx, mjs, cjs]
excludePatterns:
- "**/e2e/**"
- "**/*test*/**"
- "**/__tests__/**"
- "**/fixtures/**"
preFilter:
- { regex: "allow[_-]?list|allowList", label: "allowlist reference" }
- { regex: "senderUsername|from\\.username|displayName|dangerouslyAllowNameMatching|groupTitle", label: "mutable identity field" }
references:
- GHSA-c29c-2q9c-pc86
- GHSA-8c59-hr4w-qg69
- GHSA-cw4q-gqg5-g38h
- GHSA-7hxm-f538-3xp6
- GHSA-7w4v-g4m6-j88v
---<Security prompt goes here. See https://github.com/agentgg-dev/agentgg-agents/blob/main/openclaw/openclaw-audit-allowlist-identity-hunter.md>
It also tells the agent where to read first (monitor.ts, then its siblings, then the resolve/account/pairing files) and carries an acceptance gate matching OpenClaw's security policy (reject trusted-operator-only, multi-tenant, and parity-only findings). That gate is why it confirmed five and stayed quiet on the rest instead of flagging every displayName.
Full-repo scan of OpenClaw with the allowlist agent. We ran the agents over the whole checkout, not a diff, which is how all five turned up in one pass.
# --scope: OpenClaw's SECURITY.md, so the validator can mark out-of-scope findings
# --validate: second-pass classification --score: CVSS -v: verbose --serve: open the UI
agentgg scan .\openclaw -t openclaw-audit-allowlist-identity-hunter --scope .\SECURITY.md --validate --score -v --output .\scan-results-openclaw-full --serve
agentgg view .\scan-results-openclaw-fullPoint -t at the openclaw agent directory to run all twelve. Eleven found nothing; those bugs stayed patched.
For full base-library template scan you can run:
agentgg scan .\src -t base\ --exclude "*/e2e/*" --exclude "*/test/*" --validate --score -v --output .\scan-results-src --serve
agentgg view .\scan-results-srcInstall with npm install -g agentgg, then run agentgg init once to set a provider. Paths above are PowerShell style; on macOS or Linux use ./.
Press enter or click to view image in full size
Instead of a doc, create a runnable detector agent. The agent encodes the lesson in a markdown file and reads every pull request from then on. Agents are written in plain language, so you can draft one in the time it takes to write the postmortem.
The workflow:
Step four is the one that matters most. A one-time full-repo scan is useful and is how I ran the OpenClaw experiment, but it is not the operating mode that solves this problem long-term. A full scan tells you what is wrong today. It does not stop the next instance of the same bug from shipping next quarter. To do that, the agents need to run on every merge request. agentgg has a diff mode for exactly this. Scope the scan to the files changed in the PR, post the findings as a check, and block the merge if a confirmed finding is high enough severity. The cost per scan stays low because you are only paying for the changed files, and the feedback lands while the code is still in review.
That is the loop. You are turning your bug history into a continuously-running check that the next instance of the same mistake does not ship.
The OpenClaw experiment took twelve agents derived from past advisories and found five zero-days in a single scan. Eleven agents found nothing, which means the original bugs had stayed fixed. One agent found the same root cause repeated across five different channel extensions.
This is the pattern any large engineering org should be running against itself. Take your past incidents, turn each recurring one into an agent, and run those agents on every pull request. Your postmortems are most of the agent prompt. Your CI is most of the runner. agentgg is the rest.
If you want to try this on your own codebase, agentgg is on npm and on GitHub at agentgg-dev/agentgg. For teams that want this run for them rather than wiring it into their own CI, we are building a managed version. The waitlist is at agentgg.dev.
Site: https://agentgg.dev
npm: https://www.npmjs.com/package/agentgg
GitHub: http://agentgg.dev/