vuln-tracker
SkillDev toolsLets your agent check the status of security vulnerability reports and PRs it filed, and see what needs attention.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the vuln-tracker skill
About this capability
One lifecycle poll over everything vuln-scanner produces - PR and advisory status, PVR triage transitions, and pending-disclosure aging, with a stars-secured impact headline and one action queue.
What this skill tells your AI
The instructions your AI receives, as published by aeonfun/aeon in skills/vuln-tracker/SKILL.md and read by ahel’s review.
${var} — Scope selector for the lifecycle poll:
- empty → full lifecycle poll: PR/advisory status + PVR triage + disclosure-queue aging (default).
prs(alsopr/tracker) → Arm A only — PR/advisory status audit + stars-secured dashboard.pvr→ Arm B only — PVR triage-state poll on submitted advisories.queue(alsodisclosures/backlog) → Arm C only — pending-disclosure queue aging + escalation.- a bare
GHSA-xxxx-xxxx-xxxx→ Arm B, single-advisory mode — check just that one advisory's triage state on demand.
Today is ${today}. This skill is the daily read/poll arm of the vuln pipeline: vuln-scanner opens PRs, submits PVRs, and queues disclosure drafts, then moves on. This skill polls everything it produced and surfaces what the operator must look at — it does not open PRs or submit advisories itself (those are vuln-scanner's write actions).
Voice
If soul/SOUL.md and soul/STYLE.md are populated, read them and match the operator's voice in every notification. If empty or absent, use a clear, direct, neutral tone.
Capability mode
This skill runs mode: write deliberately. It is a read/poll arm, but three of its capabilities cannot run under read-only:
- Arm B polls private, unpublished advisory triage state via
gh api repos/$REPO/security-advisories/$GHSA. Draft/triage advisories are visible only to the repo maintainers and the reporter, so the read is intrinsically authenticated;read-onlystripsgh, and a bare$SECRETon the command line is refused by the Bash permission layer (so a hand-rolled authenticatedcurlisn't an option either) — the read needsgh api, which handles auth internally. - Arm B persists state transitions — it rewrites
state:/last_checked:/resolved_at:frontmatter inmemory/pending-disclosures/*.mdin place and moves resolved files tomemory/pending-disclosures/resolved/(Edit/git mv— both stripped inread-only). - Arm A leans on authenticated
gh apifor the PVR-state endpoint (repos/$REPO/private-vulnerability-reporting) and repo/advisory reads.
No sibling writes a repo file outside memory/, so there was nothing to relocate — the only outside-memory/ writes are the ephemeral .pending-notify-temp/ notify-staging files. Keeping write preserves every absorbed capability.
Shared preamble (run for every invocation)
- Read
memory/MEMORY.mdfor context. - Read the last ~3 days of
memory/logs/and drop anything already reported — don't re-surface the same signal twice. - Read
soul/SOUL.md+soul/STYLE.mdif populated (voice). - Parse
${var}→ scope (deterministic; trim + lowercase, except aGHSA-value which is compared case-insensitively but preserved verbatim):- empty →
scope = full(run Arm A, then B, then C). - matches
^GHSA-(case-insensitive) →scope = pvr,single_advisory = <the GHSA value>(Arm B filtered to one advisory). prs/pr/tracker→scope = prs(Arm A only).pvr→scope = pvr(Arm B only, all advisories).queue/disclosures/backlog→scope = queue(Arm C only).- anything else → log
VULN_TRACKER_BAD_VAR: unrecognized scope '<var>', send no notification, exit.
- empty →
mkdir -p .pending-notify-tempand start an empty combined-notification buffer at.pending-notify-temp/vuln-tracker-${today}.md. Each arm that has signal appends its section to this buffer; at the very end (step "Notify") the skill sends the buffer once if it is non-empty. This keeps notifications tight — a full poll with signal in two arms is one message, not two.
Then run the arm(s) selected by scope, and finish with the shared Notify and Log steps.
Network Note
- Arm A & Arm B (GitHub reads): all data via
gh api/gh search/gh pr view.ghhandles auth internally viaGH_TOKEN(and Arm B's private-advisory reads need the elevatedGH_GLOBALPAT). No env-var-authenticatedcurlfrom bash — a bare$SECRETon the command line is refused by the Bash permission layer, sogh api(auth handled internally) is the reliable path; no postprocess scripts needed. Arm B keeps a documentedcurlfallback for the advisory endpoint — see Arm B step B2 — butgh apiis preferred. - Arm C (local only): reads only local files (
memory/pending-disclosures/,memory/issues/,memory/topics/pr-status.md). No outbound network or auth required.
Arm A — PR & advisory lifecycle status (scope full or prs)
Audit the lifecycle status of every disclosure vuln-scanner has produced. Without a follow-up loop, three things rot silently:
- Merged-but-uncelebrated wins — landed fixes never reach self-improve / reflect (retro) without manual aggregation.
- Maintainer questions on open PRs — a maintainer comments asking for clarification; if the bot doesn't see it, the PR ages out.
- Queued drafts past their disclosure window — entries with
channel: "skipped"(no-safe-channel) vanish intovuln-scanned.jsonwith no recurring re-probe.
This arm cross-references memory/vuln-scanned.json against live GitHub state and surfaces anything the operator should look at.
A1. Load the canonical scan history
memory/vuln-scanned.json may be a flat top-level array of scan rows, not a {scans: [...]} object - vuln-scanner writes the flat shape. Read it in a way that accepts both:
# Accepts both the flat `[ {repo,scanned_at,findings,channel}, ... ]` shape
# and the legacy `{scans: [...]}` object.
jq -c 'if type=="array" then .[] else (.scans[]? // empty) end' memory/vuln-scanned.json 2>/dev/null
A bare jq -c '.scans[]' errors on a flat-array file (Cannot index array with string "scans") and, with 2>/dev/null swallowing the error, silently yields zero rows - so the arm reads an empty scan history and reports only what other passes happen to surface. The if type=="array" guard is the fix; it keeps the legacy object shape working too.
If memory/vuln-scanned.json doesn't exist or the parsed row count is 0, log VULN_TRACKER_SKIP: no scan history for this arm and skip Arm A (no notification section - first runs of vuln-scanner haven't happened yet). In a full poll, continue to Arm B/C.
Each scan entry has at minimum: repo, scanned_at, findings, channel, severity. Public-PR entries also have pr (URL). Pending-disclosure entries have draft_at and patch_branch. Skipped entries have reason.
Retro-active coverage: if the JSON was written after vuln-scanner started running, some PRs won't be in the JSON. Pull all bot-authored security PRs from GitHub directly (next step) to fill the gap.
A2. Pull all bot-authored security PRs from GitHub
vuln-scanner opens security-fix PRs on a security/ branch. The title varies: dependency-bump PRs use fix(deps): (the A5a template is fix(deps): bump <pkg> to patch <CVE>), and older or hand-written ones use fix(security): or a bare security:. Match all three title prefixes, and treat the security/ branch prefix as the real invariant - the branch name is mandated by A5a, the title wording is not.
Do not filter on
fix(security):alone. vuln-scanner's dominant output isfix(deps):, so afix(security):-only filter silently drops most in-flight security PRs - that is howalibaba/open-code-review#541(conflicted, lint-failing, one unaddressed review) was missed by a poll while other PRs were tracked. Also excludedocs(security):- those aresecurity-syncwebsite PRs, not disclosures.
gh search prs --json does not expose headRefName - that field is only available via GraphQL. So run both passes below every time and union them on repository + number; neither alone is complete.
The bot author is whoever the workflow uses (typically github-actions[bot] or a dedicated account configured in the workflow). Determine the author from aeon.yml or the workflow file; default to whatever account opened the most recent security-fix PR (any of the prefixes above) you can find.
Pass 1 (title prefixes, works everywhere):
BOT_AUTHOR="<resolved bot author>"
gh search prs --author "$BOT_AUTHOR" --json number,title,url,state,createdAt,closedAt,repository --limit 200 \
| jq '[.[] | select(
(.title | startswith("fix(deps):")) or
(.title | startswith("fix(security):")) or
(.title | startswith("security:"))
)]'
Pass 2 (GraphQL, required - picks up every security/ branch whatever the title says):
gh api graphql -f query='
{
search(query: "author:'"$BOT_AUTHOR"' is:pr sort:created-desc", type: ISSUE, first: 100) {
nodes { ... on PullRequest {
number title url state createdAt closedAt mergedAt
repository { nameWithOwner }
headRefName
}}
}
}' | jq '[.data.search.nodes[]
| select((.headRefName // "") | startswith("security/"))]'
Union the two result sets, dedup by repository + number (equivalently, by URL).
Cross-reference with vuln-scanned.json:
- PR present in JSON → use JSON's
severity/cwe/notefor the row. - PR not in JSON → mark as
pre-history; fill severity from the PR title if obvious. - JSON entry with
channel != "public-pr"→ no PR to fetch; goes in the "queued" / "skipped" sections.
A3. Fetch live state for each open PR
For each open PR (state from step A2):
gh pr view "$REPO/$NUM" --json state,merged,closedAt,createdAt,reviews,comments,reviewDecision,author
Per-PR signals:
- Maintainer-needs-answer: any comment whose
author.login != $BOT_AUTHORposted after the most recent comment by$BOT_AUTHOR(or after PR creation if the bot hasn't commented). AlsoreviewDecision == "CHANGES_REQUESTED"always counts. - Stale-no-review:
state == "OPEN"AND no review AND no maintainer comment ANDcreatedAt> 7d ago. - Aging-with-engagement:
state == "OPEN"AND any maintainer activity AND open > 14d.
If a scan entry has advisory_ids (one or more GHSA IDs), check each one's published state:
gh api "repos/$ORIGIN_REPO/security-advisories/$GHSA_ID" --jq '.state // "not found"'
state == "published" → public advisory visible. 404 → osv-scanner referenced it but the upstream repo never published its own advisory.
A4. Fetch star counts for every secured repo
For every unique repo across the union from step A2 (JSON history + bot-authored security PRs):
gh api "repos/$REPO" --jq '{stars: .stargazers_count, archived: .archived}' 2>/dev/null
Refetch every run. Do NOT carry star counts forward from the previous memory/topics/vuln-followup.md — per-repo counts drift between runs and the secured-stars headline is the operator's load-bearing metric. Cache only within a single run, keyed by nameWithOwner, so a repo with multiple PRs is fetched once.
Repo-state handling:
- 200 with stars: use
.stargazers_count(raw integer). - 200 with
archived: true: still use the star count, but suffix the repo cell with(archived)so the operator knows the maintainer isn't responsive. - 404 / 403: repo was deleted, renamed, or made private. Record
nulland render asrepo-deleted. Exclude fromtotal_stars_*aggregates entirely so dead repos don't quietly zero out the totals. - Other non-2xx: record
nulland render★?. Flag in the run log for operator follow-up.
These per-repo counts power both the Stars Secured aggregate (step A6) and the Stars column on every per-repo table in step A6.
A5. Re-probe channel: "skipped" and channel: "pending-disclosure" repos
For each historical entry where the disclosure couldn't ship, re-check whether the situation changed:
channel: "skipped"withreasoncontaining "no PVR":
IfPVR_NOW=$(gh api "repos/$REPO/private-vulnerability-reporting" --jq .enabled 2>/dev/null || echo "false")PVR_NOW=trueand the original wasfalse, surface as newly-actionable.channel: "skipped"withreasoncontaining "no SECURITY.md" — re-checkgh api repos/$REPO/contents/SECURITY.mdand.github/SECURITY.md. If now present, surface as newly-actionable.channel: "pending-disclosure"— cross-reference withmemory/pending-disclosures/to see if the draft is still on disk. If the file is gone but the JSON entry sayspending-disclosure, mark aslost-draftso it stops being escalated forever.
A6. Categorize every entry, then rewrite the dashboard
| Status | Meaning |
|---|---|
merged | PR merged. One-time celebration — drop from notifications after 30d. |
open-clean | PR open, no maintainer activity yet, < 7d old. Wait. |
needs-answer | Maintainer commented or requested changes. Operator action. |
stale-no-review | Open > 7d, zero maintainer activity. Consider polite ping or close. |
aging-engaged | Open > 14d with engagement. Operator should triage. |
closed-no-merge | PR closed without merging. Capture the reason for review. |
queued | pending-disclosure draft on disk, not yet shipped. |
skipped-rechecked | Channel was "skipped" originally; re-probe still shows no channel. |
newly-actionable | Skipped originally; PVR or SECURITY.md now present. Operator action. |
lost-draft | JSON says pending-disclosure but draft file is gone. Display once, then suppress. |
pre-history | PR found via search but predates vuln-scanned.json. Fill what we can. |
Then rewrite memory/topics/vuln-followup.md (rewrite — don't append; this file is a living dashboard, not a log).
The Stars Secured block goes at the top so the operator sees aggregate impact before drilling into rows. total_stars_secured = sum of stargazers across every unique repo where vuln-scanner has landed at least one merged PR. total_stars_in_flight = sum across repos with an open PR. total_stars_tracked = sum across the full union. Track all three because celebration uses secured, prioritization uses in_flight, and historical review uses tracked.
Round star counts to abbreviated form for the headline (12.4k, 1.8k, 940). Keep raw integers in the per-repo tables so sort/diff stays exact.
# Vuln Tracker Status
*Last updated: ${today}*
## Stars Secured
- **Merged-PR repos (secured):** ★ <total_stars_secured> across <secured_repo_count> repos
- **Open-PR repos (in flight):** ★ <total_stars_in_flight> across <in_flight_repo_count> repos
- **All tracked repos:** ★ <total_stars_tracked> across <total_repo_count> repos
### Secured leaderboard — every merged PR ranked by repo stars
| Rank | Repo | Stars | PR | Merged | Severity | Title |
|------|------|-------|----|--------|----------|-------|
### Per-repo breakdown — secured (sorted by stars desc)
| Repo | Stars | Merged PRs | First merge | Latest merge | Severities landed |
|------|-------|------------|-------------|--------------|-------------------|
### Per-repo breakdown — in flight (sorted by stars desc)
| Repo | Stars | Open PRs | Oldest open | Severities open |
|------|-------|----------|-------------|-----------------|
### Per-repo breakdown — queued / skipped / closed (sorted by stars desc)
| Repo | Stars | Status | Severity | Note |
|------|-------|--------|----------|------|
## Operator-action queue
### Needs answer (<count>)
| Repo | Stars | PR | Title | Last activity | Latest commenter |
|------|-------|----|----|--------------|------------------|
### Newly actionable — channel opened up since the original scan (<count>)
| Repo | Stars | Original date | Original blocker | Now |
|------|-------|---------------|------------------|-----|
### Stale or aging
| Repo | Stars | PR | Age | Status | Suggested action |
|------|-------|----|----|-----|------------------|
## Recently merged (last 30d, <count>)
| Date merged | Repo | Stars | PR | Severity | Title |
|-------------|------|-------|----|----------|-------|
## Open / clean (no operator action — wait, < 7d) (<count>)
| Repo | Stars | PR | Severity | Opened | Age |
|------|-------|----|----------|--------|-----|
## Closed without merge (last 30d, <count>)
| Date | Repo | Stars | PR | Severity | Title | Likely reason |
|------|------|-------|----|----------|-------|---------------|
## Queued (no PR yet) (<count>)
| Severity | Repo | Stars | Original channel | Original blocker | Days queued |
|----------|------|-------|------------------|------------------|-------------|
## Lost-draft ghosts (suppressed from notifications)
| Date | Repo | Stars | Severity |
A7. Decide whether Arm A has signal
No signal (contribute nothing to the notification) if all are true:
- Zero
needs-answer - Zero
newly-actionable - Zero items moved to
mergedsince the last run - Zero items moved to
closed-no-mergesince the last run - Zero items aged into
stale-no-revieworaging-engagedsince the last run
To detect "since last run," diff today's categorization against the previous memory/topics/vuln-followup.md. If the file doesn't exist (first run), treat all entries as new and surface the full backlog.
A8. Emit Arm A's notification section (when it has signal)
Append this section to the combined buffer (.pending-notify-temp/vuln-tracker-${today}.md). Keep the whole message under 4000 chars; if tight, drop the merged/opened/stale section bodies and keep counts only.
*PR & advisory status*
★ secured: <total_stars_secured> across <secured_repo_count> repos (in flight: <total_stars_in_flight> / <in_flight_repo_count>)
needs answer: <N>
<repo> (★<stars>) #<num> — <latest_commenter>: "<comment_excerpt_first_120_chars>"
newly actionable: <N>
<repo> (★<stars>, queued <days>d) — <what_changed>
merged this week: <N> <list_with_stars>
opened, waiting: <N>
stale: <N>
queued: <N> (<critical>C / <high>H / <other>M+L)
leaderboard top-3 (PRs by ★): #1 <repo1> ★<s1> (PR #<pr1>) — #2 <repo2> ★<s2> (PR #<pr2>) — #3 <repo3> ★<s3> (PR #<pr3>)
dashboard: memory/topics/vuln-followup.md (full leaderboard inside)
Record Arm A status VULN_TRACKER_OK for the log.
Arm B — PVR triage state (scope full, pvr, or a bare GHSA-…)
pvr-watchlist monitors repos waiting to open PVR. This arm monitors PVRs that have already been submitted and tracks their lifecycle: triage → draft (accepted) → published (public) or withdrawn (rejected). Without this, submitted advisories sit unmonitored until manually recalled from memory.
Source of truth: memory/pending-disclosures/*.md files with channel: pvr frontmatter. Each such file must have ghsa, repo, state, submitted_at fields (full schema below).
Configuration — optional tracking issue. The arm can reference an optional tracking issue in the operator's own repo — useful for cross-linking advisory state with an internal issue board. Resolve from (priority order):
aeon.ymltop-level keypvr_triage.tracking_issue:(e.g.pvr_triage: { tracking_issue: "owner/repo#123" })- environment variable
AEON_PVR_TRACKING_ISSUE - unset → skip cross-linking entirely.
If a tracking issue is configured, mention its URL in the notification and the per-advisory write-up so the operator can navigate to the canonical tracker.
B1. Discover in-flight PVRs
Scan memory/pending-disclosures/ for all .md files. Parse the YAML frontmatter. Keep only those with channel: pvr.
If single_advisory is set (a bare GHSA-… was passed), filter to just the matching ghsa value (one-off mode).
If no PVR files found:
PVRT_SKIP: no submitted PVRs on disk
Record that Arm B status and skip Arm B (no notification section). In a full poll, continue to Arm C.
B2. Probe each advisory's triage state
For each entry, determine repo and ghsa from frontmatter.
REPO="owner/repo"
GHSA="GHSA-xxxx-xxxx-xxxx"
gh api "repos/${REPO}/security-advisories/${GHSA}" \
--jq '{state: .state, cve_id: .cve_id, published_at: .published_at}' 2>&1
Expected outcomes:
| Response | Meaning |
|---|---|
{state: "triage", ...} | Maintainer hasn't reviewed yet |
{state: "draft", ...} | Accepted — maintainer is working on it |
{state: "published", ...} | Published — fully resolved |
{state: "withdrawn", ...} | Rejected or withdrawn by reporter |
| HTTP 403 | Private advisory, we don't have read access — state unknown, treat as still triage |
| HTTP 404 | Advisory deleted / repo private / GHSA invalid — flag as not-found |
Fallback: gh api uses GH_TOKEN internally (workflow wires GH_GLOBAL for the elevated advisory read). If gh is somehow unavailable, fall back to:
curl -s -H "Authorization: Bearer $GH_GLOBAL" \
"https://api.github.com/repos/${REPO}/security-advisories/${GHSA}" \
| grep -o '"state":"[a-z]*"'
(Note: a bare $SECRET on the command line is refused by the Bash permission layer, so gh api is the reliable path here — or route the curl through ./secretcurl with a {GH_GLOBAL} placeholder.)
B3. Detect state changes
Compare the probed state to the state in the frontmatter.
- No change: note it, continue.
- Changed: this is the primary event. Log old → new state.
Also flag:
- Aged triage:
state=triageAND (today−submitted_at) > 30 days → escalate. Most maintainers respond within 30 days; silence past that is actionable. - Accepted (draft): surface the patch branch from the
patch_branchfrontmatter field — maintainer may want a PR instead of a private advisory. - Published: advisory is live. The finding is closed. Update state and mark for removal.
- Withdrawn: rejected. Note the reason if visible. Mark for cleanup.
B4. Update frontmatter in-place
For each file with a state change, rewrite just the state field in the YAML frontmatter. Also update a last_checked field (add it if absent).
Do NOT modify the body of the advisory file — only update frontmatter.
Example frontmatter update:
state: draft # was: triage
last_checked: 2026-05-21
For published or withdrawn entries, add:
resolved_at: 2026-05-21
B5. Decide whether Arm B has signal
- All entries still
triage, no changes, none aged: no notification section. Record status and continue. - Any state change, aged entry, or action item: emit the section below.
B6. Emit Arm B's notification section (when it has signal)
Append to the combined buffer:
*PVR triage*
pvr triage: {total} advisories in flight. {changed_count} changed.
CHANGED:
- {repo} {ghsa} — {old_state} → {new_state}
{action_item}
AGED (>30d no response):
- {repo} {ghsa} — {days}d in triage. {severity}. escalate or close.
patch: {patch_branch}
STILL TRIAGE:
{n} advisories waiting. oldest: {repo} ({days}d).
{if tracking_issue configured}
tracker: {tracking_issue_url}
{end}
Action items by transition:
triage → draft→ "maintainer accepted — offer to PR the patch branch: {patch_branch}"triage → published→ "published as {cve_id}. remove from tracking."triage → withdrawn→ "rejected. remove from tracking and note in vuln-scanned.json."- aged triage (>30d) → "30d+ no response. consider pinging maintainer or withdrawing."
B7. Clean up resolved entries
For entries where state=published or state=withdrawn AND resolved_at is set: move the file from memory/pending-disclosures/ to memory/pending-disclosures/resolved/ (create the directory if needed; use git mv).
Do NOT delete — keep as a historical record.
Record Arm B status PVRT_OK for the log.
Arm C — Disclosure-queue aging (scope full or queue)
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 750
- Forks
- 264
- Last commit
- Sep 2026
ahel review
K2info
exfiltration
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Catalog kind
- skill
- Gateway key
vuln-tracker- Source
- github.com/aeonfun/aeon