Aeon Vuln Scanner
BankrBot/skills
Audit trending repos for real exploitable vulnerabilities and disclose responsibly — Private Vulnerability Reporting for code flaws and verified secrets, public PRs only for already-disclosed…
Audit trending repos for real security vulnerabilities and disclose responsibly - scan and route findings (PVR / dependency PR), re-submit queued advisories, and send armed email disclosures
$ npx skills add aeonfun/aeon --skill vuln-scanner -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install aeonfun/aeon vuln-scanner --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/aeonfun/aeon.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/vuln-scanner .claude/skills/vuln-scanner && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "vuln-scanner" agent skill from https://github.com/aeonfun/aeon/tree/main/skills/vuln-scanner into .claude/skills/vuln-scanner/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "vuln-scanner", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/aeonfun/aeon/tree/main/skills/vuln-scannerType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add aeonfun/aeon --skill vuln-scanner -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install aeonfun/aeon vuln-scanner --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/aeonfun/aeon.git skills-src && mkdir -p .agents/skills && cp -r skills-src/skills/vuln-scanner .agents/skills/vuln-scanner && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "vuln-scanner" agent skill from https://github.com/aeonfun/aeon/tree/main/skills/vuln-scanner into .agents/skills/vuln-scanner/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "vuln-scanner", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add aeonfun/aeon --skill vuln-scanner -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install aeonfun/aeon vuln-scanner --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/aeonfun/aeon.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/skills/vuln-scanner .cursor/skills/vuln-scanner && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "vuln-scanner" agent skill from https://github.com/aeonfun/aeon/tree/main/skills/vuln-scanner into .cursor/skills/vuln-scanner/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "vuln-scanner", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/aeonfun/aeon.git --path skills/vuln-scanner--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add aeonfun/aeon --skill vuln-scanner -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install aeonfun/aeon vuln-scanner --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/aeonfun/aeon.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/skills/vuln-scanner .gemini/skills/vuln-scanner && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "vuln-scanner" agent skill from https://github.com/aeonfun/aeon/tree/main/skills/vuln-scanner into .gemini/skills/vuln-scanner/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "vuln-scanner", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install aeonfun/aeon vuln-scannerInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add aeonfun/aeon --skill vuln-scanner -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/aeonfun/aeon.git skills-src && mkdir -p .github/skills && cp -r skills-src/skills/vuln-scanner .github/skills/vuln-scanner && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "vuln-scanner" agent skill from https://github.com/aeonfun/aeon/tree/main/skills/vuln-scanner into .github/skills/vuln-scanner/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "vuln-scanner", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add aeonfun/aeon --skill vuln-scanner -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install aeonfun/aeon vuln-scanner --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/aeonfun/aeon.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/skills/vuln-scanner .opencode/skills/vuln-scanner && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "vuln-scanner" agent skill from https://github.com/aeonfun/aeon/tree/main/skills/vuln-scanner into .opencode/skills/vuln-scanner/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "vuln-scanner", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
vuln-scannerAudit trending repos for real security vulnerabilities and disclose responsibly - scan and route findings (PVR / dependency PR), re-submit queued advisories, and send armed email disclosures
Vuln Scanner is an agent skill from aeonfun/aeon. Audit trending repos for real security vulnerabilities and disclose responsibly - scan and route findings (PVR / dependency PR), re-submit queued advisories, and send armed email disclosures
Its SKILL.md is about 21k tokens, which your agent loads only when the skill is triggered. The skill folder holds 1 other file (for example `riva.md`).
The repository describes itself as: The most autonomous AI agent framework: runs unattended on GitHub Actions, self-healing skills, drives Claude Code, Grok, Codex & more. No approval loops. Configure once, forget… The licence is MIT.
2 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit c0cb7c4. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
ghgitcargopython3jqcurlsemgrepclaudepipbashnpxnpmFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
github.comcloudflare-dns.comgoogle.github.ioapi.github.comdns.googleapi.resend.comx.comAlso links to:
g.coFrom URLs in SKILL.md, links to its own repository left out.
Names these keys or tokens, usually read from environment variables:
RESEND_API_KEYGH_TOKENOPENAI_API_KEYGITHUB_TOKENFrom names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Vuln Scanner loads about 21k tokens when it runs. Until then it costs about 51 tokens; SKILL.md has 8,096 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from aeonfun/aeon at commit c0cb7c4, republished under its MIT licence (© aeonfun). 8,096 words, ~20,602 tokens.
.claude/skills/vuln-scanner/SKILL.md (or your agent's skills folder). This skill also uses 1 other file; get the full folder from GitHub.<!-- autoresearch: variation B — responsible-disclosure-first: private reports for code vulns, public PRs only for already-disclosed dep CVEs -->
${var} — Action selector, shaped
[<action>][:<owner/repo>]. Empty or a bareowner/repo→ scan arm (audit that repo, or auto-select a trending one).resubmit/resubmit:owner/repo→ re-submit arm (probe the security watchlist for repos that just enabled PVR and submit any queued advisory).disclose/
- `` → scan, auto-select from trending
openai/whisper→ scanopenai/whisperresubmit→ probe the whole watchlist and re-submit what flippedresubmit:vercel/next.js→ probe just that repo (one-off)disclose(aliaspoc-smoke→ exercise the PoC gate against a benign real Base fork (no audit or disclosure)riva:owner/repo→ scan with the Riva research kernel (shadow/proposal only)
Today is ${today}. Read memory/MEMORY.md and the last 30 days of memory/logs/ before starting.
This is the write / action arm of the vuln-disclosure loop — one skill covering the full responsible-disclosure lifecycle:
SECURITY.md, and no reachable contact, it has no safe channel — so it logs the finding as "channel": "skipped" in memory/vuln-scanned.json and stages a watchlist row. Without a weekly probe those findings silently age until the responsible-disclosure window closes. The re-submit arm closes that loop.memory/pending-disclosures/ with status: pending-operator-send, waiting for a human. The disclose arm finds drafts explicitly armed for auto-send, composes the email, and sends it in-run (Resend via ./secretcurl) behind a set of fail-closed caps — the send is the arm's final action.${var}, then run one armParse the selector once, then jump to the matching arm below:
SEL="${var}" # the raw selector
ACTION="${SEL%%:*}" # token before ':' (or the whole thing)
TARGET="${SEL#*:}"; [ "$TARGET" = "$SEL" ] && TARGET="" # token after ':' (empty if no ':')
case "$ACTION" in
resubmit|watchlist|pvr) ARM="resubmit" ;; # → Arm B
disclose|email) ARM="disclose" ;; # → Arm C
poc-smoke|verify-gate) ARM="poc-smoke" ;; # → Arm D
riva|research) ARM="scan"; KERNEL="riva" ;; # → Arm A, Riva kernel
shadow|compare) ARM="scan"; KERNEL="shadow" ;; # → Arm A, private comparison
""|scan) ARM="scan" ;; # → Arm A (auto-select if TARGET empty)
*/*) ARM="scan"; TARGET="$SEL" ;; # bare owner/repo → scan that repo
*) ARM="scan" ;; # unknown → default to scan
esac
# Legacy remains the safe default while Riva is evaluated. `shadow` produces a
# private comparison artifact; only an explicit `riva` selector or a reviewed
# environment override selects it for a scan. Neither mode changes disclosure
# authority or bypasses A4/A4.5.
KERNEL="${KERNEL:-${VULN_RESEARCH_KERNEL:-legacy}}"
case "$KERNEL" in legacy|shadow|riva) ;; *) KERNEL="legacy" ;; esacARM=scan → Arm A — SCAN (target = $TARGET, or auto-select if empty).ARM=resubmit → Arm B — RE-SUBMIT (probe $TARGET if set, else the whole watchlist).ARM=disclose → Arm C — DISCLOSE (queue armed email drafts).ARM=poc-smoke → Arm D — PoC GATE SMOKE (benign live-fork verification only).Each arm is independently executable. The operational arms share the same GitHub token and the same memory/ state (vuln-scanned.json, security-watchlist.md, pending-disclosures/, email-log.json) — that shared state is exactly how the arms hand off to each other. The smoke arm does not read or modify that state.
Find one trending repo, run purpose-built scanners (not raw grep), triage to real exploitable findings, and route each finding to the correct disclosure channel — PVR, SECURITY.md contact, or dependency-bump PR.
If $TARGET is set, use it. Otherwise:
# Prefer chained output from github-trending skill
CANDS=""
if [ -s output/.chains/github-trending.md ]; then
# Parse owner/repo targets. Accept BOTH the markdown form [owner/repo](url) and a
# bare github.com/owner/repo permalink (a feeder degraded under read-only can emit
# the latter). A repo name with no owner is NOT a candidate. Pick the first CANDS
# entry that matches the criteria below.
CANDS=$(grep -oE '\[[^]]+/[^]]+\]\(https?://[^)]+\)|https?://github\.com/[^/ )]+/[^/ )]+' output/.chains/github-trending.md \
| sed -E 's#.*github\.com/##; s#\).*##; s#^\[##; s#\].*##' | grep -E '^[^/ ]+/[^/ ]+$' | sort -u)
fi
# If the feed was absent OR present-but-unparseable (zero owner/repo lines, e.g. a
# header-only / prose-only notify body), do NOT stop here - that starves the scan.
if [ -z "$CANDS" ]; then
# Shadow runs have no GitHub credentials by design. A bare shadow selector
# may consume the chained trending output above, but otherwise must fail
# closed and be retried as shadow:owner/repo.
if [ "$KERNEL" = shadow ]; then
echo "VULN_SCANNER_SKIPPED shadow-target-required: use shadow:owner/repo or provide github-trending chain output"
exit 0
else
gh api "search/repositories?q=created:>$(date -u -d '14 days ago' +%Y-%m-%d)&sort=stars&order=desc&per_page=25" \
--jq '.items[] | select(.fork==false) | select(.stargazers_count>=50) | {full_name, language, description, security_and_analysis}'
fi
fiSelection criteria:
memory/logs/ for the repo name)SECURITY.md AND security_and_analysis.private_vulnerability_reporting.status != "enabled" — you have no safe channel to report code flaws (you can still run a dep-scan and skip code audit; see step A5)REPO="owner/repo"
if [ "$KERNEL" = shadow ]; then
git clone --depth 200 --quiet "https://github.com/${REPO}.git"
else
gh repo fork "$REPO" --clone --default-branch-only -- --depth 200 --quiet
fi
cd "$(basename "$REPO")"Raw grep produces too many false positives. Use tools with dataflow reachability and verified-secret matching.
Stage the scanners in-run into /tmp/bin (see the install preamble below). The
network is open, but pip install / a curl-piped-to-shell install / tar are not on the in-run
capability allowlist — use the ones that are: python3 -m pip install … for the Python
tools (semgrep, slither) and curl -o … && chmod +x for the Go binaries (osv-scanner,
trufflehog). Put /tmp/bin on PATH and invoke
each tool by bare name — the bare names (semgrep, trufflehog,
osv-scanner, slither) are exactly what the capability allowlist
(scripts/skill_mode.sh) grants, so claude -p is permitted to execute them. If
a binary is missing, log VULN_SCANNER_SKIPPED and continue (it records fail
in sources.txt below) — never abort the whole run for one tool.
mkdir -p /tmp/vuln-scan /tmp/bin
export PATH="/tmp/bin:$PATH"
# Stage the scanners IN-RUN, best-effort, using ONLY allow-listed commands (network is
# open, but `pip install` / a curl-piped-to-shell install / `tar` are NOT allow-listed — `python3 -m pip`,
# `curl -o`, `chmod`, `npm`/`npx`, `node` ARE). Wrap each in `|| true`; any tool that fails
# to stage is skipped by the `command -v` guards below (records fail), never fatal:
python3 -m pip install --quiet --disable-pip-version-check semgrep slither-analyzer 2>/dev/null || true
curl -sSL -o /tmp/bin/osv-scanner "https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64" 2>/dev/null && chmod +x /tmp/bin/osv-scanner || true
# trufflehog: stage its release binary the same way if a raw asset exists; else it's skipped below.
# --- SAST: Semgrep OSS ---
if command -v semgrep >/dev/null 2>&1; then
semgrep --config=p/security-audit --config=p/owasp-top-ten --config=p/secrets \
--severity=ERROR --severity=WARNING --json --quiet --timeout=300 \
--exclude=test --exclude=tests --exclude=__tests__ --exclude=spec --exclude=specs \
--exclude=fixtures --exclude=examples --exclude=example --exclude=demo \
--exclude=vendor --exclude=node_modules --exclude=dist --exclude=build --exclude=.next \
-o /tmp/vuln-scan/semgrep.json . 2>/dev/null || true
else
echo "VULN_SCANNER_SKIPPED: semgrep not available"
fi
# --- Secrets: TruffleHog (only-verified = actually authenticates) ---
if command -v trufflehog >/dev/null 2>&1; then
# BOTH passes are bounded. Run these verbatim; if you invoke trufflehog (or any
# scanner) yourself, it still needs the `timeout 300` wrapper and must not be
# backgrounded with `&` — a scan still running when you write the report is a
# scan you did not finish, and a one-shot workflow_dispatch run cannot resume it.
TRUFFLEHOG_RC=0
timeout 300 trufflehog filesystem . --only-verified --json \
> /tmp/vuln-scan/trufflehog.json 2>/dev/null || TRUFFLEHOG_RC=$?
[ "$TRUFFLEHOG_RC" = 124 ] && echo "VULN_SCANNER_TIMEOUT: trufflehog filesystem scan exceeded 300s on a very large tree — recorded as fail, not retried, not left unfinished"
# Also scan full git history for secrets — BOUNDED. An unbounded `trufflehog git`
# walks every commit's every tree, and a large packed history (measured: 200
# commits / ~369MB on one real run) can eat the whole turn budget by itself,
# with nothing to show it happened until the run reports "success" anyway
# having produced no report at all. `timeout` turns that silent budget-burn
# into an ordinary, honestly-recorded `fail` — same as an install failure,
# never a reason to write a "still running, will resume" placeholder as the
# final output. There is no resume: a workflow_dispatch run is one shot, and
# a note promising to pick back up later is not truthful about what a single
# run can actually do.
TRUFFLEHOG_GIT_RC=0
timeout 300 trufflehog git file://. --only-verified --json \
> /tmp/vuln-scan/trufflehog-git.json 2>/dev/null || TRUFFLEHOG_GIT_RC=$?
[ "$TRUFFLEHOG_GIT_RC" = 124 ] && echo "VULN_SCANNER_TIMEOUT: trufflehog git history scan exceeded 300s on a large packed history — recorded as fail, not retried, not left unfinished"
else
echo "VULN_SCANNER_SKIPPED: trufflehog not available"
fi
# --- Dependencies: osv-scanner (unified CVE DB across ecosystems) ---
# osv-scanner v2 (what `releases/latest` now installs, 2.4.x) moved scanning under the
# `scan source` subcommand. The v1 bare form (`osv-scanner --recursive .`) still works on
# 2.x, so try v2 first and fall back to v1 only if v2 wrote NOTHING (keyed on emptiness,
# NOT exit code - osv exits 1 when it FINDS vulns, which must not read as a syntax error).
# `--no-ignore` is REQUIRED: v2 `scan source` respects .gitignore by default, so a target
# repo that gitignores its (committed) lockfile - common for libraries/tools - yields the
# misleading "No package sources found" (exit 128) and zero dependency coverage. A shipped-
# but-gitignored lockfile still describes real deps, so a security scan must read it. It is
# a no-op when lockfiles are tracked.
if command -v osv-scanner >/dev/null 2>&1; then
osv-scanner scan source --recursive --no-ignore --format=json . > /tmp/vuln-scan/osv.json 2>/dev/null; OSV_RC=$?
[ -s /tmp/vuln-scan/osv.json ] || { osv-scanner --format=json --recursive --no-ignore . > /tmp/vuln-scan/osv.json 2>/dev/null; OSV_RC=$?; }
# Classify: exit 128 = "No package sources found" = the repo has no lockfiles/manifests
# to scan. That is a clean N/A (nothing to do), NOT a scan failure - osv writes an EMPTY
# file in that case, so `[ -s ]` alone would mislabel it `fail`. Distinguish the states:
if [ -s /tmp/vuln-scan/osv.json ]; then OSV_STATUS=ok # ran; results present (0 or N dep CVEs)
elif [ "${OSV_RC:-}" = 128 ]; then OSV_STATUS=none # ran; no dependency lockfiles -> n/a
else OSV_STATUS=fail; fi # genuine tool error
else
OSV_STATUS=skipped
echo "VULN_SCANNER_SKIPPED: osv-scanner not available"
fi
# --- Smart-contract scan (if Solidity present) ---
if ls **/*.sol >/dev/null 2>&1 && command -v slither >/dev/null 2>&1; then
slither . --json /tmp/vuln-scan/slither.json --exclude-informational --exclude-low 2>/dev/null || true
fi
# Record what succeeded (empty output ≠ clean, could be tool failure)
echo "semgrep=$([ -s /tmp/vuln-scan/semgrep.json ] && echo ok || echo fail)" > /tmp/vuln-scan/sources.txt
# TruffleHog JSON is finding-only: an exit-0 empty stream is a clean scan.
if [ "${TRUFFLEHOG_RC:-1}" = 124 ]; then
echo "trufflehog=timeout" >> /tmp/vuln-scan/sources.txt
else
echo "trufflehog=$([ "${TRUFFLEHOG_RC:-1}" = 0 ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt
fi
# Recorded separately from the filesystem pass above: they can genuinely diverge
# (filesystem scan clean and fast, git-history scan timed out on a large packed
# repo, or vice versa) and collapsing both into one trufflehog= line hides
# whichever one actually failed.
if [ "${TRUFFLEHOG_GIT_RC:-1}" = 124 ]; then
echo "trufflehog-git=timeout" >> /tmp/vuln-scan/sources.txt
elif [ "${TRUFFLEHOG_GIT_RC:-1}" = 0 ]; then
echo "trufflehog-git=ok" >> /tmp/vuln-scan/sources.txt
else
echo "trufflehog-git=fail" >> /tmp/vuln-scan/sources.txt
fi
echo "osv=${OSV_STATUS:-fail}" >> /tmp/vuln-scan/sources.txtStatic tools never execute the target's code, so they can't catch a bug that only
shows up on a specific malformed input. Some repos already carry their own fuzz
harnesses (cargo fuzz) for exactly this. If the clone has one, run it — this is
a different technique from A3, not a better version of it, and it finds a
different class of bug.
Scope, on purpose: Rust + cargo fuzz only, for this pass. stage-vuln-scanner.sh
installs a nightly toolchain and cargo-fuzz for every run (bounded, ~1-2 min:
the runner already has stable Rust) so this step never needs an in-run install —
it degrades to a skip exactly like a missing scanner does. Other ecosystems have
their own fuzzers (libFuzzer/AFL for C/C++, go-fuzz, atheris for Python, Trident
for Solana/Anchor) — worth adding the same way later, each gated on its own
command -v guard, but out of scope here.
This is a real trade-off, not a free scanner: semgrep/trufflehog/osv-scanner only read the target's files. This compiles and runs the target's own code (and whatever it pulls in) inside the sandboxed run. That's the same trust boundary any CI system already accepts when it builds a repo's test suite — the runner is ephemeral and only holds this skill's own scoped secrets — but it's a step up from A3, so it only activates when the repo hands you a harness rather than probing for one, and it never touches the network beyond what cloning the repo already did.
if [ -d fuzz/fuzz_targets ] && command -v cargo-fuzz >/dev/null 2>&1; then
mkdir -p /tmp/vuln-scan/fuzz
# Seed real inputs where they exist — an empty corpus rarely gets a mutator
# past a magic-byte header, so this is the difference between a shallow run
# and one that reaches real parsing logic.
for target in $(cargo fuzz list 2>/dev/null); do
if [ -d "tests/fixtures" ]; then
mkdir -p "fuzz/corpus/$target"
find tests/fixtures -iname "*.${target}" -exec cp {} "fuzz/corpus/$target/" \; 2>/dev/null
fi
done
# Bounded: a handful of targets, ~90s each. This is a smoke test for "does
# anything crash immediately," not a real fuzzing campaign — a real one runs
# for hours and belongs to the maintainer's own CI, not a weekly scan.
#
# This step compiles and runs the target's own code (and every dependency's
# build.rs) with network open. Scrub this skill's own secrets from the
# env first — a malicious target could otherwise exfiltrate them at compile
# time via a build script:
n=0
for target in $(cargo fuzz list 2>/dev/null); do
[ "$n" -ge 8 ] && break
n=$((n + 1))
env -u GH_TOKEN -u GH_GLOBAL -u RESEND_API_KEY -u RESEND_FROM -u RESEND_REPLY_TO \
cargo +nightly fuzz run "$target" -- -max_total_time=90 \
> "/tmp/vuln-scan/fuzz/${target}.log" 2>&1 || true
done
echo "fuzz=$([ -n "$(ls /tmp/vuln-scan/fuzz 2>/dev/null)" ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt
else
echo "VULN_SCANNER_SKIPPED: no fuzz/fuzz_targets or cargo-fuzz unavailable"
fiA crash artifact lands at fuzz/artifacts/<target>/crash-*. Reproduce it clean
before it counts as anything: env -u GH_TOKEN -u GH_GLOBAL -u RESEND_API_KEY -u RESEND_FROM -u RESEND_REPLY_TO cargo fuzz run <target> fuzz/artifacts/<target>/crash-*
(same secret-scrubbing as the run above — this also compiles and executes the
target's code) and read the actual panic message and call stack, not just the
"deadly signal" summary line.
Root-cause it before routing it — a crash under fuzz/ can mean three
different things, and they route differently:
firecrawl/anydoc's xlsx target surfaced a crash inside calamine, not in
anydoc's own code. Report and, if the fix is small and matches the
dependency's own existing conventions, fix it in the dependency's repo,
not the original target — the target's only actionable next step is bumping
a version once one exists. A dependency panic is DoS-only (Rust panics
safely; this is not a memory-safety finding) and, absent a published CVE
already covering it, the fix usually is the disclosure: small, obvious,
reviewable, no exploit chain to redact. A PR is the appropriate channel for
that case even without PVR on the dependency's repo — same logic as A5's
dependency-CVE row, just for a bug you found instead of one already public.unwrap() on
setup code outside the code path being fuzzed). Not a finding — drop it,
same as a scanner false positive.For KERNEL=riva or KERNEL=shadow, load skills/vuln-scanner/riva.md as the
focused research kernel. In shadow, produce a private comparison artifact
and leave legacy routing authoritative. In riva, Riva supplies the
threat-model, invariant, slice, and bounded exploration contract; this skill
remains authoritative for tool execution, triage, PoC verification,
disclosure, and persistence. Do not load disclosure or lifecycle instructions
while doing the Riva code-exploration pass. With KERNEL=legacy, follow the
existing A3.6 procedure unchanged.
Before the Riva pass, build the compact target dossier from the Aeon checkout (the current working directory may still be the target clone):
RIVA_REPO="$PWD"
cd "${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}"
./scripts/build-vuln-context.sh --repo "$RIVA_REPO" \
--out /tmp/vuln-scan/riva-context.json \
--history "${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}/memory/vuln-scanned.json"
cd "$RIVA_REPO"Read only /tmp/vuln-scan/riva-context.json plus the selected code slice during
the Riva exploration pass. In KERNEL=shadow, write Riva's private candidate
and coverage comparison to /tmp/vuln-scan/riva-shadow.json; do not route it
to PVR, email, or a public PR. The legacy candidate set remains authoritative.
Semgrep matches syntactic patterns and has weak dataflow reachability on custom code; fuzzing (A3.5) only reaches what a harness already drives. Both are blind to authorization, business-logic, and multi-step trust-boundary bugs. That whole class is what an agentic reviewer catches - and here you are the agentic scanner. Do the source-to-sink reasoning the tools can't, over this repo's real entrypoints. This pass runs on every scan (unlike A3.5, which only fires when the repo ships a fuzz harness) and produces candidates, not verdicts - everything still goes through A4 triage (the model surfacing a finding is not evidence it is real).
Bounded so it can't run away on run time. Size the repo first, then set the entrypoint review budget N from it - deep-review the top-N highest-exposure entrypoints only, and note the rest in the A7 report as reviewed-but-not-deep so coverage stays honest:
# Cheap size probe (excludes the usual noise dirs). Drives the review budget below.
CODE_FILES=$(find . -type f \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' \
-o -name '*.py' -o -name '*.go' -o -name '*.rs' -o -name '*.sol' -o -name '*.rb' \
-o -name '*.java' -o -name '*.php' \) \
-not -path '*/node_modules/*' -not -path '*/vendor/*' -not -path '*/dist/*' \
-not -path '*/build/*' -not -path '*/.git/*' 2>/dev/null | wc -l | tr -d ' ')
if [ "${CODE_FILES:-0}" -le 300 ]; then N=15 # small repo - review broadly
elif [ "${CODE_FILES:-0}" -le 1500 ]; then N=10
else N=6; fi # large repo - top exposure only
echo "agentic-budget: CODE_FILES=$CODE_FILES N=$N"0. Frame the threat model first (one paragraph, before you enumerate). State what this app is, the 2-3 things an attacker most wants from it (RCE, auth bypass / IDOR, secret/data exfil, SSRF into internal infra), and its trust boundaries (who is authenticated where, what input is server- vs user-controlled). This targets the ranking in step 2 so the top-N budget lands on what actually matters, not just the first entrypoints you find. Keep it to a few lines; it is the plan, not a deliverable.
1. Build the entrypoint inventory - every place untrusted input enters. Grep + read to enumerate; record file:symbol and a kind for each:
exec / spawn / system / eval sinks + subprocess with string interpolation (RCE)2. Rank by exposure, deep-review the top N. Order entrypoints by attack surface against the step-0 threat model (unauthenticated + reachable + dangerous-sink, weighted toward what the attacker most wants, first). For the top N: trace source-to-sink - what the attacker controls, where it flows, the sink, and the guard (if any) between. Write the one-sentence attacker-control claim (the A4 bar). Prioritize reachable production paths; ignore tests/examples/docs. Note entrypoints past N in the A7 report as not-deep-reviewed - do not silently drop them.
3. Emit candidates in the same shape the tool outputs feed A4. Write one JSON array (may be []) to /tmp/vuln-scan/agentic.json with the Write tool:
[
{"file":"src/api/user.ts","line":88,"severity":"high","category":"idor",
"claim":"unauthenticated GET /user/:id returns any user's record - no owner check"}
]# after writing /tmp/vuln-scan/agentic.json, record the source status:
echo "agentic=ok" >> /tmp/vuln-scan/sources.txt # 0 candidates on a reviewed surface is still `ok`;
# use `agentic=skipped` only if the repo is unreadable/opaque
# (minified-only, generated, no source you can reason about)Optional - codex-security as an extra source. If OPENAI_API_KEY is present and npx is allow-listed and the CLI is staged, npx @openai/codex-security scan . --json produces an independent agentic findings.json; merge its findings into /tmp/vuln-scan/agentic.json and record codex=ok. Off by default - it needs Node 22.13+, model credits, and an allow-listed npx; the Claude-native pass above is the baseline and needs no new infra. (Verify the subcommand/flags against the installed version first - it is early 0.1.x and churns.)
A scanner hit - or a candidate from the A3.6 agentic pass - is a candidate, not a vulnerability. Merge the array in /tmp/vuln-scan/agentic.json (if present) into the tool findings, then for each candidate:
Drop the finding if:
test/, mock/, fixture/, example/, demo/, bench/, docs/If 0 findings survive triage → log "clean audit — N candidates reviewed, 0 confirmed" and exit cleanly.
This gate exists because a plausible pattern plus a hand-written narrative can still be wrong. A code finding may be called HIGH or CRITICAL, counted as confirmed, or routed to disclosure only after an executable verifier reproduces the exact attacker-controls → attacker-achieves claim against the audited commit. “The test compiled,” “Foundry ran,” or “the scanner rated it high” are not sufficient.
Two evidence classes do not need a new PoC:
--only-verified secrets — the scanner has already authenticated the
credential. Still assess blast radius honestly; “valid” does not automatically mean
Critical.Every other provisional HIGH/CRITICAL code finding must use
./scripts/vuln-poc-gate.sh from the Aeon checkout (the directory that
contains that script), not from inside the A2 clone. The write-tier
allowlist is Bash(./scripts/vuln-poc-gate.sh:*). Pass the clone path as
--repo. The gate supports:
foundry — mandatory for Solidity/on-chain findings that depend on live state.
It resolves a public RPC from deploy-uni-hook's reviewed chains.tsv, reads the real
chain id and current block, pins the fork to that block, stages the private test only
for the command, and removes it afterward.command — a deterministic local regression/reproduction script for non-Solidity
findings. It must exit 0 only when the claimed security boundary is actually crossed.
Never point it at a production service or third-party live target.Both modes run target code in a clean, allowlisted environment so GitHub,
disclosure, and harness-provider credentials are not inherited. Raw PoC source and
tool output stay under /tmp/vuln-scan/; the public workflow log
gets only a redacted verifier verdict. Never print or commit the PoC for an unpatched
finding.
Create /tmp/vuln-scan/poc/<id>.json (use an opaque id — no vulnerable symbol or
file name) with the Write tool:
{
"id": "finding-1",
"target_repo": "owner/repo",
"target_commit": "<git rev-parse HEAD>",
"severity": "high",
"attacker_controls": "<specific input or capability, at least 10 chars>",
"attacker_achieves": "<specific boundary crossed, at least 10 chars>"
}The runner rejects a changed commit, an underspecified claim, or any severity other
than high/critical. Its result includes the SHA-256 of this exact claim file, so do
not edit the finding after verification; re-run the gate if the claim changes.
Write a minimal Foundry test to /tmp/vuln-scan/poc/<id>.t.sol. Its test_poc_*
function must assert the prohibited outcome — attacker profit/ownership, bypassed
authorization, invariant loss, or other concrete impact — not merely “the call did
not revert.” Then run:
# A2 left cwd inside the clone. The gate script lives in the Aeon repo.
CLONE="$PWD"
cd "${GITHUB_WORKSPACE}"
./scripts/vuln-poc-gate.sh foundry \
--finding /tmp/vuln-scan/poc/finding-1.json \
--repo "$CLONE" \
--test-file /tmp/vuln-scan/poc/finding-1.t.sol \
--chain base \
--match-contract AeonPoC \
--match-test '^test_poc_'Use the chain the affected deployment actually lives on. A passing test on a blank
local chain does not verify a real-state claim. The runner records the observed chain
id and fork block in /tmp/vuln-scan/poc-results/<id>.json.
Write /tmp/vuln-scan/poc/<id>.sh so it sets up only local fixtures, exercises the
real production entrypoint, checks the concrete prohibited outcome, and exits nonzero
when the claim is not reproduced. Then run:
CLONE="$PWD"
cd "${GITHUB_WORKSPACE}"
./scripts/vuln-poc-gate.sh command \
--finding /tmp/vuln-scan/poc/finding-1.json \
--repo "$CLONE" \
--script /tmp/vuln-scan/poc/finding-1.shDo not weaken assertions until the script turns green. A failed reproduction is evidence against the claim, not an obstacle to work around.
verdict: "verified", the audited target_commit, and a matching
finding_sha256 → the claim may retain HIGH/CRITICAL and proceed to A5.needs-verification; do not
count it as confirmed, do not send/file anything, and surface it to the operator.If KERNEL=shadow, do not execute A5's write actions. Record the legacy and
Riva candidate sets, triage differences, and verification differences in the
private shadow artifact, then continue to A6–A8 with channel: shadow and no
PVR, email, issue, or public-PR side effect. Shadow mode is comparison-only.
This is the core of the scan arm. Channel selection has two stages, in order: first honor the channel the repo's own security policy designates (A5.0), and only then fall back to routing by finding type (the matrix below).
Before choosing any channel, read the repo's own security policy and obey it. GitHub PVR being technically enabled on a repo does not make it the right channel - many large vendors (Google, Microsoft, GitLab, ...) have PVR available org-wide but triage security reports through their own PSIRT intake. Filing a GitHub PVR there means the report lands in a queue they don't watch and silently violates their stated process. (Confirmed 2026-07-02: a code flaw in google/agents-cli was filed as GitHub PVR GHSA-x742-5676-qjpj, ignoring the repo's SECURITY.md, which says "Please use https://g.co/vulnz to report security vulnerabilities.")
SECURITY.md, .github/SECURITY.md, docs/SECURITY.md, and the README's "Security" / "Reporting a Vulnerability" section. Also check the org-level default {owner}/.github repo (gh api /repos/{owner}/.github/contents/SECURITY.md --jq '.content|@base64d') - GitHub inherits that policy for every repo in the org that lacks its own, so a repo with no SECURITY.md of its own may still have a binding one. (This is exactly how google/agents-cli was missed: it has no repo-level file, but google/.github/SECURITY.md designates g.co/vulnz, and the run recorded "No SECURITY.md" because it never checked the org fallback.) Use gh api for the raw file, or WebFetch as fallback.g.co/vulnz, bughunters.google.com, msrc.microsoft.com, a hackerone.com/... or bugcrowd.com/... program, a vendor "report a vulnerability" form. Stage for the operator at memory/pending-disclosures/ (a portal needs human/web submission). Do NOT file GitHub PVR.security@..., psirt@..., a named contact) - out-of-band email: stage the auto-send-ready draft (see A5b's 403 branch) for the disclose path (Arm C)./reports (A5b). This is the only case where GitHub PVR is the right first choice.If the designated channel can't be actioned by the agent (most portals need human/web submission), stage it for the operator at memory/pending-disclosures/ - never substitute GitHub PVR as a "backup". That recreates the exact wrong-channel duplicate this step exists to prevent. Stage a portal draft with status: pending-operator-send, a portal_url: field (the exact intake URL) and auto_send: false with no contact_email - so Arm C's email sender skips it and it surfaces as an operator-todo (channel portal) rather than mis-arming it as an email send. Do not also open a GitHub PVR for the same finding.
Otherwise, route by finding type:
| Finding type | Channel | Why |
|---|---|---|
| Dependency CVE (osv-scanner hit) | Public PR bumping the dep | CVE is already public; a patch PR is net-positive |
| Code vulnerability (Semgrep/agentic, A4.5 verifier passed where HIGH/CRITICAL) | PVR (GitHub private advisory) | Unpatched code flaw — public disclosure creates a zero-day |
| Verified leaked secret (TruffleHog verified) | PVR + tell maintainer to rotate | Publishing the file/line in a public PR tells attackers where to look |
| Smart-contract issue (Slither candidate; Foundry fork verifier passed for HIGH/CRITICAL) | PVR | On-chain exploitation is often immediate and irreversible |
| Fuzz crash in the target's own code | PVR | Same as any other code vulnerability — see A3.5 |
| Fuzz crash in a dependency | Public PR to the dependency's repo (fix, not just a report, if it's small and matches their conventions) | DoS-only, no exploit chain to redact — see A3.5 case 2 |
| No PVR enabled AND no SECURITY.md | Private issue to maintainer if possible, else skip and log | No safe channel = do no harm |
git checkout -b security/bump-<pkg>-<cve>
# Update lockfile/manifest
git add -A
git commit -m "fix(deps): bump <pkg> to patch <CVE-YYYY-NNNN>
Advisory: <link to GHSA or NVD>
Severity: <high/critical>
Fixed in: <version>"
git push -u origin HEAD
gh pr create --repo "$REPO" \
--title "fix(deps): bump <pkg> to patch <CVE-YYYY-NNNN>" \
--body "$(cat <<EOF
Automated dependency bump to address a disclosed CVE.
- **CVE:** <id>
- **Advisory:** <url>
- **Severity:** <severity>
- **Package:** \`<name>\` → \`<fixed-version>\`
Detected by [osv-scanner](https://google.github.io/osv-scanner/). No code changes outside the lockfile/manifest.
---
Filed by [Aeon](https://github.com/aeonframework/aeon).
EOF
)"Preflight - confirm PVR is actually enabled before composing or filing. Do not infer PVR status from a POST failure; read the dedicated flag first. It returns an explicit true/false, which is distinct from an auth 403, so a degraded GH_GLOBAL token can't masquerade as "PVR disabled" and silently misroute a code flaw to email (the exact false-negative that historically pushed enabled-PVR repos onto the email path).
# A5b.0 - preflight PVR status. Branch on the HTTP STATUS CODE, NOT on `--jq .enabled`:
# on a non-200 response `gh api --jq` prints the ERROR BODY (a JSON blob), NOT empty - so the
# old `--jq '.enabled' 2>/dev/null` returned `{"message":"Not Found",...}` on a 404 (private /
# missing / renamed repo) and the same on a 403/429 rate-limit, which a naive `=="true"` test
# silently reads as "not enabled" and MISROUTES a code flaw. That is the "clanky PVR API"
# false-negative. Read the status code once and set an explicit state (verified 2026-07-15:
# public repos = 200 {"enabled":true|false}; private/missing = 404 error blob):
PVR_RESP=$(gh api "repos/$REPO/private-vulnerability-reporting" -i 2>/dev/null)
PVR_CODE=$(printf '%s' "$PVR_RESP" | awk 'NR==1{print $2; exit}')
case "$PVR_CODE" in
200) [ "$(printf '%s' "$PVR_RESP" | tail -1 | jq -r '.enabled')" = "true" ] \
&& PVR_STATE=enabled || PVR_STATE=disabled ;;
404) PVR_STATE=unavailable ;; # repo has no PVR surface (often private) - NOT "disabled"
403|429) PVR_STATE=access-unknown ;; # auth / secondary rate-limit - could not read the flag
*) PVR_STATE=access-unknown ;; # 5xx / transient
esac
# enabled : compose + POST /reports (below)
# disabled : SKIP the POST; out-of-band fallback (A5b 403 branch) + watchlist row
# unavailable : out-of-band fallback (often a private repo); no auth alarm needed
# access-unknown : do NOT assume PVR-off. Raise the auth alarm (A5b 403 branch) and retry the
# probe once; never silently email/misroute on a transient error.Only when PVR_STATE is enabled do you compose and POST the report:
# Private third-party reporting uses the /reports endpoint. Do NOT use the bare
# /security-advisories endpoint — that *creates* an advisory and requires
# admin/security-manager rights on the target repo, so it returns 403 on any repo
# you don't own. Classic `repo` scope is sufficient for /reports;
# `repository_advisories:write` is NOT required for third-party reporting.
#
# ⚠️ CRITICAL: the payload MUST include a non-empty `vulnerabilities` array.
# The REST docs mark it "optional", but the create handler returns **HTTP 500
# (empty body)** when it is omitted. This single bug is why every bare-API PVR
# in this project historically failed and got routed to the web form — the form
# only works because it always collects "affected products" (= vulnerabilities).
# Verified 2026-06-26: identical {summary,description} payload → 500 without the
# array, 201 with it. Always send at least one {package:{ecosystem,name}}.
#
# Write the advisory markdown to /tmp/pvr-body.md first (Summary / Impact /
# Location / Proof / Suggested fix / Detected by), then build the JSON payload
# (jq -Rs safely encodes the multi-line body) and POST it via --input:
cat > /tmp/pvr.json <<JSON
{
"summary": "<short title>",
"description": $(jq -Rs . < /tmp/pvr-body.md),
"severity": "<critical|high|medium|low>",
"cwe_ids": ["CWE-89"],
"vulnerabilities": [
{ "package": { "ecosystem": "pip", "name": "<pkg-or-repo-name>" } }
]
}
JSON
# ecosystem ∈ pip|npm|go|maven|nuget|composer|rubygems|rust|erlang|actions|pub|swift|other
gh api -X POST "/repos/$REPO/security-advisories/reports" \
-H "X-GitHub-Api-Version: 2022-11-28" --input /tmp/pvr.jsonAlways POST via --input <file>, never a long inline heredoc / -f description="$(cat …)" — the latter can trip Claude Code's Bash command analyzer ("Unhandled node type: string"), and vulnerabilities is a nested array that -f/-F can't express cleanly. Write the full JSON payload ({summary, description, severity, cwe_ids, vulnerabilities} — vulnerabilities is mandatory, see the ⚠️ note above) to a temp file and gh api -X POST … --input payload.json.
Read the HTTP response code and branch accordingly. Never fall back to a public issue or a code-fix PR for an unpatched flaw (that publishes a zero-day):
201 → reported. Record the report/advisory id and link it in the local report.
403 "Repository does not have private vulnerability reporting enabled" → PVR is OFF on the repo. This is not a token-scope problem (classic repo scope is enough). Critically: the GitHub advisory web form (/security/advisories/new) is the SAME PVR backend — it returns 404 to external reporters when PVR is off. Do NOT stage that URL as the channel even if SECURITY.md recommends it (a SECURITY.md that only says "use the advisory form" is not a usable channel when PVR is disabled — confirmed on agent-reach and world-of-claudecraft, 2026-06-19). Resolve an out-of-band private contact instead, in this order: (1) SECURITY.md email / portal / vendor PSIRT; (2) README contact (email / Discord / X); (3) package metadata — pyproject.toml / setup.py author, package.json author + bugs; (4) the maintainer/owner's git commit email or GitHub profile. Stage a maintainer-ready report at memory/pending-disclosures/<repo>-<timestamp>.md in the auto-send-ready format (see below) so the disclose arm (Arm C) can send it, and add a row to memory/security-watchlist.md so the re-submit arm (Arm B) will re-check PVR status. Only if no out-of-band contact exists anywhere, log "no safe channel — skipped".
Auto-send-ready draft format (consumed by Arm C's in-run send):
---
repo: owner/repo
severity: <critical|high|medium|low>
cwe: CWE-NN
status: pending-operator-send
auto_send: <true|false> # ARMING GATE — see the rule below
contact_email: maintainer@example.com
cc: [security@example.com] # optional — if SECURITY.md says "email X, cc Y/Z"
email_subject: "Security: <short title>"
detected_at: <ISO-8601>
---
# Staged private disclosure — owner/repo
<operator-facing notes: contact resolution, why private — NOT emailed>
<!-- EMAIL-BODY-START -->
Hi <name>,
<the exact private message: where / the issue / why it matters / severity /
suggested fix / offer to share a patch>
Thanks,
Aeon (https://github.com/aeonframework/aeon)
<!-- EMAIL-BODY-END -->Write the EMAIL-BODY as PLAIN TEXT — it is sent as a plain-text email, so any
Markdown renders literally to the maintainer. No **bold**, no # headings, no
backtick code spans, no [text](url) links. Use plain prose; label sections with
plain words and a colon (Where: not **Where:**); paste bare URLs; keep code or
argv samples as plain indented lines (those read fine in plain text). Only the
EMAIL-BODY block needs this — the operator-facing notes above it may use Markdown.
Do not hard-wrap paragraphs mid-sentence: write each paragraph as one line,
separated by a blank line (the sender also auto-de-wraps soft-wrapped lines, but
authoring them unwrapped keeps the draft clean). Keep deliberate short breaks — the
greeting and the Thanks, / signature — on their own lines.
auto_send rule (this is the only safeguard before a real send):
auto_send: true only when a valid contact_email resolved AND the repo does not ban AI-generated security reports (check SECURITY.md — many do).auto_send: false when the only contact is non-email (X/Discord), the email couldn't be validated, or the repo bans AI reports. A false draft waits for the operator to send manually (set human_only: true too if the ban is explicit). Never arm a draft you'd be uncomfortable auto-sending.500 (empty body) on a PVR-enabled repo → in this project this has always meant the vulnerabilities array was missing/empty (the create handler crashes instead of returning a clean 422; see the ⚠️ note above). This is fixable in-band, not a reason to fall back: ensure the payload carries at least one {package:{ecosystem,name}} and re-POST once. Verified 2026-06-26 — the same body went 500 → 201 purely by adding the array. Only if a report with a valid non-empty vulnerabilities array still 5xxs is the endpoint genuinely broken for this repo: then (and only then) stage the report in memory/pending-disclosures/ and have the operator file it via the web form https://github.com/<repo>/security/advisories/new (a different frontend to the same PVR backend), without retry-spamming. (Contrast the 403 PVR-disabled case above, where the form 404s too — route to an out-of-band contact instead.)
Any other failure → stage in memory/pending-disclosures/ and surface to the operator; never publish.
Dependency-bump PRs (step A5a) are the only public channel. Hardening-class code findings (e.g. DNS-rebinding / Host-Origin allowlists) may be offered as a neutral public PR at operator discretion, but high-severity exploitable flaws (RCE, auth bypass, secret exposure, sandbox/guardrail escape) must stay on a private channel.
If you have a minimal fix, push it to your fork only (not a PR to upstream) and link it in the PVR description so the maintainer can cherry-pick:
git checkout -b private/fix-<slug>
# apply fix
git commit -m "draft: proposed patch for reported advisory"
git push -u origin HEAD
# DO NOT open a PR. Link the branch in the advisory body.Append to memory/vuln-scanned.json (create if missing) so future runs skip this repo for 30 days:
{"repo": "owner/repo", "scanned_at": "2026-04-20T16:00:00Z", "findings": <N>, "channel": "pvr|public-pr|skipped"}There is no resume. The git-history pass has a timeout; this does not bound
every other scanner or installation step. If you are running low on turns, finish
the report with whatever scanners actually completed, record the rest fail in
sources.txt (§A3's rule: unfinished is fail, not a pending state), and write
A7/A8 now. A single workflow_dispatch run is one shot with no continuation —
writing "still running, will pick this up automatically" as the final output is
not true of this run path (it was live-observed: a run reported workflow
success having written that sentence instead of a report, with no ledger entry
at all — the operator had to notice and re-dispatch by hand). A shorter, honest
report with some scanners marked fail is a completed task; a promise to
resume is not.
Save to output/articles/vuln-scan-${today}.md with sections for: repo metadata, scanner sources (ok/fail per tool — trufflehog and trufflehog-git are two separate rows, not one; folding a timed-out history scan into a clean filesystem-scan's ok is exactly the silent-masking this split exists to prevent), candidate count, confirmed findings with severity and channel, PoC gate status (verified with verifier/chain/block, not-required with reason, or needs-verification), and dedup note. Do not include exploit details for findings disclosed via PVR — redact file/line and link to the advisory ID instead.
Copy each scanner status from sources.txt into the report, notification and log.
Keep trufflehog (filesystem) and trufflehog-git (history) separate, preserving
timeout exactly. Missing status is fail, never inferred ok. If any pass failed
or timed out, say "limited audit" and name the incomplete coverage, even when zero
findings were confirmed. Do not describe incomplete coverage as a clean audit.
Use ./notify. One paragraph. Lead with the verdict.
*Vuln Scanner — <repo>*
<N> confirmed findings (<severity-summary>).
Disclosed via: <PVR: advisory #123 | public PR #45 | skipped (no channel)>
Scanners: semgrep=<ok|fail>, trufflehog=<ok|fail|timeout>, trufflehog-git=<ok|fail|timeout>, osv=<ok|fail>, fuzz=<ok|fail|skip>. PoC gate: <verified|not-required|needs-verification>.trufflehog=timeout and trufflehog-git=timeout must each always be spelled out here, never folded into a plain ok — a clean pass and a timed-out one are different facts, and this is the durable line an operator actually reads. Silently dropping a timed-out state reproduces the exact masking this field exists to prevent.
If no findings were confirmed (choose clean or limited according to actual coverage):
*Vuln Scanner — <repo>*
<Clean audit | Limited audit — name incomplete passes>. <M> candidates reviewed, 0 confirmed. Scanners: semgrep=<ok|fail>, trufflehog=<ok|fail|timeout>, trufflehog-git=<ok|fail|timeout>, osv=<ok|fail|none|skipped>, fuzz=<ok|fail|skip>, agentic=<ok|skipped>.Then log per the Log section below with Mode: scan.
This is the safe end-to-end verification path for the PoC gate. It does not select or audit a third-party repository, create a vulnerability claim, file a report, or notify anyone. It proves that the real Aeon runner installed Foundry and that the gate can read live Base state, pin a block, execute a temp-only test, and emit correlated redacted evidence.
Run the committed opt-in integration smoke directly; no preliminary fixture build or target inspection is needed:
bash scripts/tests/live_vuln_poc_gate.shThat script creates an isolated temporary git fixture and synthetic claim, then
asserts that Base WETH (0x4200000000000000000000000000000000000006) has deployed
bytecode. This proves the EVM is a real Base fork, not an empty local chain; it is
not a security exploit or finding.
Require both VULN_POC_VERIFIED with verifier=foundry-fork, chain=base, and a
numeric positive block, plus the final live-vuln-poc-gate: PASS. Report
VULN_POC_SMOKE_OK plus the block. Any other result is VULN_POC_SMOKE_FAILED;
do not reinterpret it as success.
Log under ### vuln-scanner with Mode: poc-smoke, the verdict, chain id 8453,
fork block, and whether the redacted execution evidence existed. Send no
notification.
Probe repos on the security watchlist — check if private vulnerability reporting has been enabled, notify when status flips, re-submit any queued advisory or flag for re-research when the draft was lost. Active watchlist: memory/security-watchlist.md.
If soul/SOUL.md and soul/STYLE.md are populated, match the operator's voice in the notification. If empty or absent, use a clear, direct, neutral tone.
Read memory/security-watchlist.md. Parse each row in the table:
| owner/repo | severity | short-title | first-checked | last-checked | status |If $TARGET is set (resubmit:owner/repo), skip the file and probe only that target (one-off mode).
If the watchlist is empty or the file doesn't exist:
PVRL_SKIP: watchlist emptyLog it and stop. No notification needed.
For each repo, run:
REPO="owner/repo"
gh api "repos/${REPO}/private-vulnerability-reporting" --jq '.enabled' 2>&1Expected responses:
true — PVR is now enabled. This is the flip we're watching for.false — PVR still disabled. Note it, move on.404 — Repo may have been deleted / renamed / made private. Flag as not-found.403 — Token lacks scope or it's a private repo. Flag as access-denied.Note: gh CLI handles auth internally - no token-in-URL needed. If gh api is unavailable, fall back to ./secretcurl with the {GH_TOKEN} placeholder (the workflow sets GH_TOKEN from GH_GLOBAL; never a raw curl with $GH_GLOBAL):
./secretcurl -s -H "Authorization: Bearer {GH_TOKEN}" \
"https://api.github.com/repos/${REPO}/private-vulnerability-reporting" | grep -o '"enabled":[a-z]*'For each repo where PVR flipped to true:
a) Check for a recoverable draft
Look in memory/pending-disclosures/ for a file whose name starts with the repo slug (replacing / with -).
SLUG=$(echo "$REPO" | tr '/' '-')
ls memory/pending-disclosures/${SLUG}*.md 2>/dev/nullb) If a draft exists and status is not shipped:
Attempt auto-submission via the PVR /reports endpoint (NOT the bare
/security-advisories endpoint — that creates an advisory and needs
admin/security-manager rights on the target repo, so it 403s on any repo you
don't own). Classic repo scope is sufficient.
gh api -X POST "repos/${REPO}/security-advisories/reports" \
-H "X-GitHub-Api-Version: 2022-11-28" \
--input <draft-content-as-json>Build the JSON body from the draft file fields:
summary → first heading linedescription → full advisory bodyseverity → from **Severity:** fieldcwe_ids → array from **CWE:** field (e.g. ["CWE-639"])vulnerabilities → MANDATORY, non-empty — [{ "package": { "ecosystem": "<pip|npm|go|…|other>", "name": "<pkg>" } }]. ⚠️ Omitting it makes the endpoint return HTTP 500 (empty body), not a clean error (the docs wrongly mark it optional). This is the #1 PVR-submission failure; always include it. See Arm A step A5b.If the POST returns 201: mark draft as status: submitted, update memory/vuln-scanned.json channel to pvr-submitted, and note in the watchlist row status: submitted.
If the POST returns 500 (empty body): the vulnerabilities array is almost certainly missing/empty — add it and re-POST once before treating it as a real failure.
If the POST returns 403 (PVR disabled / scope): keep status as pvr-enabled-pending-submit. Notify operator to submit manually via the GitHub web form.
c) If no draft exists (draft was lost):
Do NOT attempt a blind submission. Instead, flag the entry as pvr-enabled-needs-reresearch: the finding needs to be re-discovered before it can be submitted. This should trigger a targeted scan (Arm A) on the repo.
Rewrite memory/security-watchlist.md with updated last-checked and status for every entry. Status values: pvr-disabled | pvr-enabled-pending-submit | submitted | not-found | access-denied | pvr-enabled-needs-reresearch.
Remove entries where status: submitted AND the submission happened more than 30 days ago (they're done; lifecycle tracking is handled by vuln-tracker Arm B from there).
pvr-disabled: no notification. Log counts and stop.pvr-enabled-needs-reresearch: send urgent notification — window may be closing.Write to a temp file, then: ./notify -f .pending-notify-temp/pvr-watchlist-${today}.md
pvr watchlist: {total} repos. {flip_count} flipped this run.
FLIPPED:
- {repo} — {severity}, PVR now enabled. {draft_status}
[draft found → auto-submitted | draft found → bot 403, manual submit needed | no draft → re-research needed]
STILL WAITING:
{n} repos still pvr-disabled. oldest: {repo} ({days}d since first scan).
watchlist: memory/security-watchlist.mdIf a re-research is needed, escalate urgency:
pvr watchlist: {repo} flipped. no draft — needs re-research before the window closes.
HIGH severity. scanned {first_checked}. {days_since}d ago.
no draft on disk. need a targeted vuln-scanner run to recover the finding.
run: gh workflow run aeon.yml -f skill=vuln-scanner -f var={repo}Then log per the Log section below with Mode: resubmit.
memory/security-watchlist.md is a Markdown table maintained by this arm. Add new entries manually or via Arm A's "no safe channel" branch. Schema:
# Security Watchlist
Repos where we have a staged advisory but no disclosure channel yet.
Updated automatically by the vuln-scanner re-submit arm.
| Repo | Severity | Finding | First Checked | Last Checked | Status |
|------|----------|---------|---------------|--------------|--------|
| owner/repo | HIGH | Short title | YYYY-MM-DD | YYYY-MM-DD | pvr-disabled |When Arm A finds an exploitable code flaw (not a public dep CVE) in a repo that has neither PVR enabled nor a usable SECURITY.md/PR channel, the only responsible disclosure path is a private email to the maintainer. Those drafts sit in memory/pending-disclosures/ with status: pending-operator-send. This arm finds drafts explicitly armed for auto-send, composes each email, and sends it in-run via Resend (./secretcurl). The send is an irreversible outbound call, so it is the arm's final step and is fail-closed: it happens only behind every cap in C4 (kill-switch, daily budget, per-maintainer cooldown, dedup ledger, recipient sanity, secret tripwire) — any check that fails, is unset, or errors means do not send, never send anyway.
This is fully autonomous (operator chose this): an armed draft is sent without waiting for a human. That makes the arming gate the only safeguard, so this arm is conservative — it queues only drafts that pass every check below, and the post-send notification tells the operator exactly what went out.
This is outbound mail to third parties. It shares the Resend account with the operator-notify email channel (which mails the operator) but is a distinct purpose and from-address. Do not conflate them.
A .md file in memory/pending-disclosures/ is eligible iff:
auto_send: true. Missing or false → skip (this is the
master gate; Arm A sets it false whenever the repo bans AI-generated reports or the
contact couldn't be validated).contact_email: that matches a
plausible email (^[^@\s]+@[^@\s]+\.[^@\s]+$).status: is one of pending-operator-send, auto-send-ready,
pending, or blank. Anything else (email-sent, email-failed, hold, sent,
submitted, withdrawn, superseded-upstream, contact-unverified) → skip. (email-failed means
the sender gave up after repeated failures — leave it for the operator.)memory/email-log.json matches this draft
(slug, or repo + to), and status isn't already email-sent.Hard exclusions (skip even if armed, and log a warning so the operator notices the
mis-arm): status: hold, any frontmatter human_only: true / ai_report_ban: true,
or a body still containing operator-only scaffolding (e.g. "Operator action required",
"do not publish") inside the extracted region.
If zero drafts are eligible → log DISCLOSURE_EMAILER_SKIP: nothing armed and stop.
No notification on an empty/nothing-armed run — only send the summary notify (C4) when something actually goes out.
ls memory/pending-disclosures/*.md 2>/dev/null
jq -c '.[]' memory/email-log.json 2>/dev/null # [] if absent — seed it as [] if missingIf memory/pending-disclosures/ is empty → DISCLOSURE_EMAILER_SKIP: queue empty, stop.
For each file, parse the YAML frontmatter and apply the eligibility checklist above.
Build the dedup key from frontmatter repo (slug = repo with /→-) or the
filename. Cross-check against memory/email-log.json and against the draft's own
status.
The draft separates operator-facing scaffolding from the email that actually goes out. Extract deterministically:
Subject: frontmatter email_subject:. (Legacy fallback only if absent: the
first Subject: line in the body.)
Body: everything between the markers
<!-- EMAIL-BODY-START -->
... the exact message the maintainer receives ...
<!-- EMAIL-BODY-END -->(Legacy fallback only if no markers: the text after the first --- separator that
follows the Subject: line, through end of file.)
CC: frontmatter cc: — for repos whose SECURITY.md says "email X, cc Y and Z".
May be a YAML list (cc: [y@x.com, z@x.com]) or a comma-separated string. Pass it
straight through in the queued JSON's cc field. The operator audit address
(RESEND_CC) is added automatically by the sender — do not add it here. Validate
each cc as a plausible email; drop any that aren't.
Safety: if you cannot isolate a clean body (no markers AND no usable fallback), or the isolated body still contains operator-scaffolding phrases, skip the draft and log it — never risk emailing the preamble. Do not invent or rewrite the body; send exactly what the draft author staged.
The arm dispatches at most one email per day (a deliberate drip — see Guidelines),
so sort eligible drafts by severity (critical → high → medium → low), then oldest
detected_at first — the single daily slot must go to the most important disclosure.
Then send, in that order, applying every gate below. Any gate that fails, is unset, or
errors ⇒ do not send that draft — leave it for a later run; never fall through to
sending. Only ./secretcurl, jq, python3, grep, date, echo, mkdir, and the
Write/Edit tools are available (no mv/awk/sha256sum/mktemp).
Global gates (once, before the loop):
$DISCLOSURE_EMAIL_PAUSED in 1/true/yes/on → log DISCLOSURE_EMAILER_SKIP: paused, stop.${VAR:+x} form — a bare $RESEND_API_KEY trips the secret-expansion analyzer and falsely reads as unset (idiom documented in narrative-tracker): { [ -n "${RESEND_API_KEY:+x}" ] && [ -n "${RESEND_FROM:+x}" ]; }. If either is unset → log DISCLOSURE_EMAILER_SKIP: resend not configured, stop (drafts stay queued; nothing lost).memory/email-log.json to [] if missing/corrupt. SENT_TODAY = jq '[.[]|select((.sent_at//"")|startswith($TODAY))]|length' — if that isn't a clean integer (ledger unreadable), fail closed: stop, send nothing. Else BUDGET = min(${DISCLOSURE_EMAIL_MAX_PER_RUN:-1}, ${DISCLOSURE_EMAIL_DAILY_CAP:-1} - SENT_TODAY); if BUDGET <= 0 → DISCLOSURE_EMAILER_SKIP: daily cap, stop.Per draft (stop the loop once BUDGET sends have gone out):
4. Dedup / status. Skip if slug (repo with /→-) is already a row in memory/email-log.json, or the draft's own status: is already email-sent/email-failed.
5. Recipient sanity. to must match ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ (grep -qE) — else skip + warn.
5b. Deliverability (MX/A), fail-closed. A syntactically valid address can still be a dead or mistyped domain. Before an autonomous send, confirm the recipient domain can actually receive mail at the DNS level (port-25 SMTP probing is blocked on hosted runners, so verify over DNS-over-HTTPS - a plain GET, so ./secretcurl passes the placeholder-free URL straight through):
DOMAIN="${TO#*@}"
DNS=$(./secretcurl -sS --max-time 10 "https://dns.google/resolve?name=${DOMAIN}&type=MX")
MXN=$(echo "$DNS" | jq -r '[.Answer[]? | select(.type==15)] | length' 2>/dev/null)MXN >= 1 (domain publishes MX) → verified, proceed.MXN == 0: retry once against Cloudflare (./secretcurl -sS --max-time 10 -H 'accept: application/dns-json' "https://cloudflare-dns.com/dns-query?name=${DOMAIN}&type=MX"). Still none → look up an A record (&type=A, .type==1): a domain with an A but no MX still accepts mail at that host (RFC 5321 §5.1), so treat a resolvable A as deliverable.status: contact-unverified and add a deliverability: no-mx frontmatter line so it drops out of the eligible set and surfaces to the operator; do not consume the budget.to was emailed within ${DISCLOSURE_EMAIL_COOLDOWN_DAYS:-7} days (latest .to→.sent_at in the ledger, python3 datetime diff) → skip, leave queued, retry after the window. A cooled-down draft does not consume the budget — move to the next.grep -qE '(sk-[A-Za-z0-9]{20}|re_[A-Za-z0-9]{8}[A-Za-z0-9_]{12}|gh[pousr]_[A-Za-z0-9_]{20}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20}|-----BEGIN [A-Z ]*PRIVATE KEY-----)' → do not send, log BLOCKED: possible secret in body, leave for operator review.cc (array or comma-string) + $RESEND_CC (operator audit copy), minus blanks and the to, deduped (jq).python3, reading RESEND_FROM/RESEND_REPLY_TO from os.environ (never a --arg from "$RESEND_FROM" on the line — that risks the analyzer block); pass only the non-secret to/subject/body/cc as argv. Then POST with ./secretcurl (the {RESEND_API_KEY} header placeholder is substituted inside the script); the clean slug is the idempotency key:PAYLOAD=$(python3 - "$TO" "$SUBJECT" "$BODY" "$CC_JSON" <<'PY'
import os, sys, json
to, subject, text, cc = sys.argv[1], sys.argv[2], sys.argv[3], json.loads(sys.argv[4] or "[]")
p = {"from": os.environ["RESEND_FROM"], "to": [to], "subject": subject, "text": text}
if os.environ.get("RESEND_REPLY_TO"): p["reply_to"] = os.environ["RESEND_REPLY_TO"]
if cc: p["cc"] = cc
print(json.dumps(p))
PY
)
./secretcurl -sS --max-time 30 -w 'http=%{http_code}\n' -X POST "https://api.resend.com/emails" \
-H "Authorization: Bearer {RESEND_API_KEY}" -H "Content-Type: application/json" \
-H "Idempotency-Key: $SLUG" -d "$PAYLOAD"http=<code>. Body has .id ⇒ sent; no .id / non-2xx ⇒ failed.{slug,repo,to,subject,resend_id,sent_at} to memory/email-log.json (via python3/Write — no mv), and flip the draft's frontmatter status: email-sent (+ email_id/email_sent_at/email_to) with Edit/python3. Decrement BUDGET.send_attempts in frontmatter; once it reaches ${DISCLOSURE_EMAIL_MAX_ATTEMPTS:-3}, flip status: email-failed so it stops retrying and the operator can fix the contact. Leave the draft queued otherwise (retried next run). Do not decrement BUDGET on failure.After the loop, if anything sent (or hard-failed), send one ./notify summary — the drafts that went out (repo → to, with the Resend id) and any that gave up (email-failed, need operator). Nothing sent and nothing failed ⇒ no notification.
Then log per the Log section below with Mode: disclose.
---
repo: owner/repo
severity: medium
cwe: CWE-88
status: pending-operator-send # eligible trigger
auto_send: true # MASTER GATE — false if AI-report ban / unvalidated contact
contact_email: maintainer@example.com
cc: [security@example.com, oss@example.com] # optional — if SECURITY.md says "cc X and Y"
contact_x: https://x.com/handle # optional secondary
email_subject: "Security: <short title>"
detected_at: 2026-06-26T19:26:00Z
---
# Staged private disclosure — owner/repo
**Operator-facing notes** (NOT emailed): context, why private, contact resolution…
<!-- EMAIL-BODY-START -->
Hi <name>,
<the exact private disclosure message — where, the issue, why it matters,
severity, suggested fix, and an offer to share a patch/coordinate>
Thanks,
Aeon (https://github.com/aeonframework/aeon)
<!-- EMAIL-BODY-END -->Append to memory/logs/${today}.md under one consolidated heading. The first
bullet is the discriminator line naming which arm ran; then include that arm's
specific bullets.
### vuln-scanner
- Mode: scan | resubmit | disclose | poc-smokeMode: scan — add:
- Target: owner/repo (stars, language)
- Candidates: N | Confirmed: M
- Channels used: PVR (x), public PR (y), skipped (z)
- Prior-art check: N candidates checked, 0 matches | matched #123 → skipped/commented
- Scanner status: semgrep=ok|fail trufflehog=ok|fail|timeout trufflehog-git=ok|fail|timeout osv=ok|fail|none|skipped fuzz=ok|fail|skip agentic=ok|skip poc=verified|not-required|needs-verification
- Advisory/PR links: [...]Mode: resubmit — add:
- Watched: {total} repos
- Flipped: {flip_count} ({repos_that_flipped})
- Submitted: {submitted_count}
- Still waiting: {waiting_count}
- Notification: {sent|skipped}
- PVRL_OK (or PVRL_SKIP: <reason>)Mode: disclose — add:
- Drafts scanned: {N}
- Eligible / queued: {M} ({list of repo -> contact})
- Skipped: {reasons — not-armed, already-sent, no-channel, unsafe-body}
- Note: Arm C sends in-run via Resend (`./secretcurl`) behind the C4 caps; the summary notify reports what went out
- DISCLOSURE_EMAILER_OK (or DISCLOSURE_EMAILER_SKIP: <reason>)Arm A (scan). Getting the scanners to run under GitHub Actions takes two things:
semgrep, trufflehog, osv-scanner, slither) are not pre-installed. Stage them in-run into /tmp/bin (step A3's preamble): the network is open, but pip install / a curl-piped-to-shell install / tar aren't allow-listed, so use python3 -m pip install … (semgrep, slither) and curl -o … && chmod +x for the Go binaries (osv-scanner, trufflehog). Any tool that can't be staged is skipped by its command -v guard (VULN_SCANNER_SKIPPED); if no scanner is available, Arm A reports SCAN_TOOLS_MISSING and skips the scan cleanly rather than erroring the run.claude -p runs under an --allowedTools allowlist, so any command not on it is denied ("requires approval") with no human to approve. The scanner bare names (semgrep, osv-scanner, trufflehog, slither) must be listed in the write tier of scripts/skill_mode.sh for bare invocation to be permitted; if a name is missing it's denied and that scanner is skipped (the scan arm degrades to manual code review — a denial reads as "requires approval", not a network/sandbox block). This is why step A3 puts /tmp/bin on PATH and calls each tool by bare name (semgrep …, not /tmp/bin/semgrep …) — an absolute-path invocation would not match the allowlist pattern.This two-part fix resolves ISS-001 (binaries installed and runnable). If any scanner binary is still missing at runtime, log VULN_SCANNER_SKIPPED: <tool> not available, record tool=fail in sources.txt, and continue with the remaining scanners rather than aborting the whole run. An all-scanners-fail run must report error, not clean.
A3.5 (fuzz) follows the same two-part shape, but staging happens in the workflow step, not in-run: scripts/stage-vuln-scanner.sh installs a nightly Rust toolchain and cargo-fuzz before claude -p starts (the sandbox denies toolchain installs in-run, same reason deploy-uni-hook stages Foundry the same way — see scripts/stage-deploy-uni-hook.sh). Staging is unconditional (same tolerance as slither above — the target isn't known yet at staging time), so it always installs; only use is conditional on the clone actually shipping fuzz/fuzz_targets. Execution needs Bash(cargo:*) in the write tier of scripts/skill_mode.sh — broader than the single-purpose scanner grants, because cargo fuzz dispatches through the cargo binary itself, which also compiles the target's own code (and its build scripts). This means the target's build scripts run with this skill's own secrets (GH_GLOBAL/GH_TOKEN, RESEND_API_KEY, RESEND_FROM, RESEND_REPLY_TO) live in the environment and network open — A3.5 scrubs them with env -u immediately before both the fuzz run and the crash-reproduction command, and nowhere else in this skill needs that scrub, since only this step executes the target's own code. If either staging half is missing, the command -v cargo-fuzz guard in A3.5 skips cleanly.
A4.5 (PoC verification) stages Foundry with the repository's pinned official
action before the harness starts, then invokes only ./scripts/vuln-poc-gate.sh.
The helper resolves a public RPC from deploy-uni-hook's chains.tsv, pins the fork
block, gives target code a clean environment without Aeon credentials, binds the
verdict to the finding JSON hash and audited git commit, and writes raw output only to
/tmp/vuln-scan/poc-results/. The workflow correlates the gate's redacted verdict with
a redacted forge [redacted] execution record; it must never log the private test path
or arguments.
Arm B (re-submit). gh api uses the GH_TOKEN env var internally (the workflow wires GH_GLOBAL in). If gh api fails, use the ./secretcurl fallback in step B2. No outbound auth-required calls except gh api.
Arm C (disclose). The send is an irreversible outbound call (a disclosure email), so it runs in-run as the arm's final action, behind the C4 fail-closed caps. Make the Resend POST with ./secretcurl and the {RESEND_API_KEY} placeholder — a bare $RESEND_API_KEY on the command line is refused by the Bash permission layer. RESEND_API_KEY / RESEND_FROM / RESEND_REPLY_TO are injected in-run via this skill's requires:; RESEND_CC + the DISCLOSURE_EMAIL_* caps are read from the run env. There is no deferred/postprocess step — a failed send is logged (email-failed after the attempt cap), not queued to a later runner.
General network rules: curl works, with WebFetch as the fallback for a plain URL fetch. For anything requiring a token, use gh api (handles auth internally) or ./secretcurl with a {ENV_NAME} placeholder. Irreversible side-effects run in-run as a skill's final fail-closed action (see CLAUDE.md) — there is no deferred/postprocess gate, and Arm C's send already runs in-run.
GH_TOKEN / GITHUB_TOKEN — required for Arm A. Classic repo scope is sufficient, including private vulnerability reporting via the /reports endpoint (step A5b / B3). repository_advisories:write is only needed to manage advisories on repos you own — it is not required to report to third-party repos, and its absence is not the reason a report fails (see step A5b for the real failure modes: a missing vulnerabilities array → 500 (by far the most common — fixable in-band), PVR-disabled 403, or a genuine GitHub API 5xx).GH_GLOBAL — GitHub PAT with public_repo + repository_advisories:write scope, used by Arm B (re-submit) for cross-repo gh api calls (and, as GH_TOKEN, the ./secretcurl fallback). Same token family as Arm A. Optional (Arm B falls back to the ambient gh auth where present).RESEND_API_KEY — Resend API key, used in-run by Arm C's send (injected via requires:). If unset, Arm C skips the send and drafts stay queued (no send, no error). Optional.RESEND_FROM — verified sender, e.g. Security <disclosures@send.example.com>.
Must be on a domain/subdomain verified in Resend (SPF+DKIM+DMARC). A subdomain
is recommended so disclosure mail can't damage the root domain's reputation.RESEND_REPLY_TO — a human inbox, so maintainer replies reach the operator.RESEND_CC — always CC'd on every disclosure (operator audit copy).DISCLOSURE_EMAIL_PAUSED — set to 1 to freeze all sending instantly (kill-switch).DISCLOSURE_EMAIL_MAX_PER_RUN — emails per execution (default 1).DISCLOSURE_EMAIL_DAILY_CAP — emails per UTC day across all runs (default 1);
computed from the ledger so a manual dispatch can't exceed it.DISCLOSURE_EMAIL_MAX_ATTEMPTS — after this many failed sends a draft is flagged
status: email-failed and stops being retried (default 3).DISCLOSURE_EMAIL_COOLDOWN_DAYS — never email the same recipient (the to
address) twice within this many days, even across different repos (default 7;
0 disables). Checked against the ledger; CC'd people are exempt.Scan (Arm A):
memory/vuln-scanned.json).needs-verification; never lower the label merely to route around the gate.fuzz/fuzz_targets isn't there, A3.5 skips, same as a missing scanner.Disclose (Arm C):
auto_send: true. If a
HIGH/CRITICAL code flaw clearly needs sending but isn't armed, surface it for the
operator — do not arm it yourself in this arm.text, so Markdown renders literally
to the maintainer. Drafts are authored plain (no **bold** / # / `code` /
links) by Arm A. If you see a draft body full of Markdown, that's an authoring bug —
flag it for the operator rather than emailing the asterisks; don't silently rewrite it.memory/email-log.json.auto_send: false by design — leave them for the operator.© aeonfun, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
SKILL.md and 1 other file in skills/vuln-scanner of aeonfun/aeon.
Open the folder on GitHubat commit c0cb7c4
Vuln Scanner next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Vuln Scanner this skillaeonfun/aeon | 767 | — | ~21k | Automated safety check: Pass | MIT | |
| Aeon Vuln ScannerBankrBot/skills | 1.2k | — | ~858 | Automated safety check: Pass | None | |
| Xss Vulnerability Scannerjeremylongshore/tons-of-skills-marketplace | 2.8k | — | ~593 | Automated safety check: Pass | MIT | |
| Algorand Vulnerability Scannertrailofbits/skills | 7.4k | — | ~3.1k | Automated safety check: Pass | CC-BY-SA-4.0 | |
| Substrate Vulnerability Scannertrailofbits/skills | 7.4k | — | ~3.2k | Automated safety check: Pass | CC-BY-SA-4.0 | |
| Cairo Vulnerability Scannertrailofbits/skills | 7.4k | — | ~3.3k | Automated safety check: Pass | CC-BY-SA-4.0 |
BankrBot/skills
Audit trending repos for real exploitable vulnerabilities and disclose responsibly — Private Vulnerability Reporting for code flaws and verified secrets, public PRs only for already-disclosed…
jeremylongshore/tons-of-skills-marketplace
Scan xss vulnerability scanner operations. An agent skill from jeremylongshore/tons-of-skills-marketplace.
trailofbits/skills
Scans Algorand TEAL and PyTeal contracts for 11 known vulnerability patterns, such as unchecked rekeying and fees, and reports each with severity and a fix.
trailofbits/skills
Scans Substrate/Polkadot pallets for 7 critical vulnerabilities including arithmetic overflow, panic DoS, incorrect weights, and bad origin checks.
trailofbits/skills
Scans Cairo and StarkNet contracts for 6 vulnerability patterns, including felt252 overflow, L1 to L2 messaging faults, address conversion and signature replay.
trailofbits/skills
Scans TON (The Open Network) smart contracts for 3 critical vulnerabilities including integer-as-boolean misuse, fake Jetton contracts, and forward TON without gas checks.
aeonfun/aeon
Browses open tasks on the TaskMarket agent-worker market and, with explicit operator approval, creates tasks, tracks submissions and submits finished work.
aeonfun/aeon
Sets up and manages an Aeon agent instance that runs skills on a schedule through GitHub Actions: starting, rescheduling, debugging, editing skills and mining chat history.
aeonfun/aeon
Reads a Base Account's address, portfolio and transaction history through the Base MCP server, and stays strictly read-only in unattended Aeon runs, reporting only changes.
aeonfun/aeon
Audits every page of a site each day from its sitemap, scores on-page and technical SEO, checks duplicates across pages and reports what changed since the last run.
aeonfun/aeon
5 concrete real-life actions, leverage-scored against open loops with specificity and anti-fluff gates
aeonfun/aeon
Static linter for an Aeon instance's configuration that catches silent failures such as unquoted schedules, duplicate keys, unconfigured skills and broken MCP references.
Audit trending repos for real security vulnerabilities and disclose responsibly - scan and route findings (PVR / dependency PR), re-submit queued advisories, and send armed email disclosures. Vuln Scanner is an agent skill from aeonfun/aeon.
Run `npx skills add aeonfun/aeon --skill vuln-scanner -a claude-code`. Or copy the skill folder (skills/vuln-scanner in aeonfun/aeon) into .claude/skills/vuln-scanner in your project. Claude Code loads it when a task matches its description.
Run `npx skills add aeonfun/aeon --skill vuln-scanner -a codex`. Or copy the skill folder (skills/vuln-scanner in aeonfun/aeon) into .agents/skills/vuln-scanner in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add aeonfun/aeon --skill vuln-scanner -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/vuln-scanner, .gemini/skills/vuln-scanner, .github/skills/vuln-scanner and .opencode/skills/vuln-scanner in your project.
Going by SKILL.md and its folder, Vuln Scanner needs the command-line tools its instructions call (gh, git, cargo, python3, jq and curl) and credentials named RESEND_API_KEY, GH_TOKEN, OPENAI_API_KEY and GITHUB_TOKEN. Our summary lists: Python 3; A credential in RESEND_API_KEY.
SKILL.md names 8 domains. In commands or code: github.com, cloudflare-dns.com, google.github.io, api.github.com, dns.google, api.resend.com and x.com; the agent is likely to contact these when it follows the instructions. As links in the text: g.co. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Vuln Scanner is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 21k tokens (SKILL.md is roughly 82k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Vuln Scanner: Aeon Vuln Scanner (BankrBot/skills, 1.2k stars), Xss Vulnerability Scanner (jeremylongshore/tons-of-skills-marketplace, 2.8k stars), Algorand Vulnerability Scanner (trailofbits/skills, 7.4k stars) and Substrate Vulnerability Scanner (trailofbits/skills, 7.4k stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
aeonfun (a GitHub organization) maintains it in aeonfun/aeon, which has 767 GitHub stars. The repository holds 82 skills in this directory. The repository was last updated on October 8, 2026.
Source: aeonfun/aeon on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.