# Vuln Scanner

> 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

- Skill: `aaronjmars/vuln-scanner` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add aaronjmars/vuln-scanner`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aaronjmars/vuln-scanner/raw
- Safety review: WARNING
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: aaronjmars (https://skillmd.com/u/aaronjmars)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aaronjmars/vuln-scanner

---

<!-- 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 bare `owner/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` / `email` → **disclose** arm (queue armed out-of-band email disclosures for sending). Examples:
> - `` → scan, auto-select from trending
> - `openai/whisper` → scan `openai/whisper`
> - `resubmit` → probe the whole watchlist and re-submit what flipped
> - `resubmit:vercel/next.js` → probe just that repo (one-off)
> - `disclose` (alias `email`) → arm & queue eligible disclosure emails

Today is ${today}. Read `memory/MEMORY.md` and the last 30 days of `memory/logs/` before starting.

## Why this skill exists

This is the **write / action arm of the vuln-disclosure loop** — one skill covering the full responsible-disclosure lifecycle:

- **Scan** — a security scanner that dumps unpatched vulnerabilities into public PRs is a zero-day publisher, not a helper. This skill matches industry practice: **Private Vulnerability Reporting (PVR) for code flaws, public PRs only for dependency CVEs that are already public**. Bad disclosure burns credibility and puts users at risk.
- **Re-submit** — when a scan finds a HIGH/CRITICAL issue in a repo with no PVR, no `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.
- **Disclose** — when the only responsible path is a private email to the maintainer, drafts sit in `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.

## Dispatch — parse `${var}`, then run one arm

Parse the selector once, then jump to the matching arm below:

```bash
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
  ""|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
```

- `ARM=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).

Each arm is independently executable. All three 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.

---

## Arm A — SCAN

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.

### A1. Pick a target

If `$TARGET` is set, use it. Otherwise:

```bash
# Prefer chained output from github-trending skill
if [ -s output/.chains/github-trending.md ]; then
  # parse owner/repo lines; pick first that matches criteria below
  :
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
```

Selection criteria:
- Language you can reason about (JS/TS, Python, Go, Rust, Solidity)
- ≥50 stars, not a fork, active in last 6 months
- Handles untrusted input: auth, crypto, network, file I/O, templating
- **Skip** if scanned in last 30 days (grep `memory/logs/` for the repo name)
- **Skip** deliberately vulnerable teaching repos (DVWA, juice-shop, webgoat, vulnerable-*, *-ctf, hackme-*)
- **Skip** repos with no `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)

### A2. Fork and clone

```bash
REPO="owner/repo"
gh repo fork "$REPO" --clone --default-branch-only -- --depth 200 --quiet
cd "$(basename "$REPO")"
```

### A3. Run purpose-built scanners

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` / `curl | sh` / `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.

```bash
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` / `curl | sh` / `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
  trufflehog filesystem . --only-verified --json \
    > /tmp/vuln-scan/trufflehog.json 2>/dev/null || true
  # Also scan full git history for secrets
  trufflehog git file://. --only-verified --json \
    > /tmp/vuln-scan/trufflehog-git.json 2>/dev/null || true
else
  echo "VULN_SCANNER_SKIPPED: trufflehog not available"
fi

# --- Dependencies: osv-scanner (unified CVE DB across ecosystems) ---
if command -v osv-scanner >/dev/null 2>&1; then
  osv-scanner --format=json --recursive . \
    > /tmp/vuln-scan/osv.json 2>/dev/null || true
else
  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
echo "trufflehog=$([ -s /tmp/vuln-scan/trufflehog.json ] && echo ok || echo fail)" >> /tmp/vuln-scan/sources.txt
echo "osv=$([ -s /tmp/vuln-scan/osv.json ] && echo ok || echo fail)"              >> /tmp/vuln-scan/sources.txt
```

### A4. Triage — read every finding before trusting it

A scanner hit is a candidate, not a vulnerability. For each candidate:

1. **Open the file at the reported line** and read the surrounding 30–50 lines.
2. **Write one sentence** describing what an attacker controls and what they achieve. If you can't, discard it.
3. **Check the call path** — is the vulnerable function reachable from external input in production code (not tests, docs, examples)?
4. **Severity**: critical (RCE, auth bypass, secret exposure), high (SQLi, stored XSS, SSRF, path traversal), medium (reflected XSS, weak crypto, missing rate limit).
5. **Assign disclosure channel** per step A5.

Drop the finding if:
- It's in `test/`, `mock/`, `fixture/`, `example/`, `demo/`, `bench/`, `docs/`
- It's behind a feature flag not enabled by default
- It requires attacker privileges equal to or greater than the attack yields
- You'd be embarrassed to defend it to the maintainer

If 0 findings survive triage → log "clean audit — N candidates reviewed, 0 confirmed" and exit cleanly.

### A5. Route each finding to the correct disclosure channel

This is the core of the scan arm. Pick the channel 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 ERROR/WARNING, verified exploitable) | **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 high/medium) | **PVR** | On-chain exploitation is often immediate and irreversible |
| **No PVR enabled AND no SECURITY.md** | **Private issue** to maintainer if possible, else skip and log | No safe channel = do no harm |

#### A5a. Public PR (dependency CVEs only)

```bash
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
)"
```

#### A5b. Private Vulnerability Report (code flaws, verified secrets, contract bugs)

```bash
# 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.json
```

**Always 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):

  ```markdown
  ---
  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* `5xx`s 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 `404`s 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.

#### A5c. Proposed code patch (optional, paired with A5b)

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:

```bash
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.
```

### A6. Update dedup state

Append to `memory/vuln-scanned.json` (create if missing) so future runs skip this repo for 30 days:

```json
{"repo": "owner/repo", "scanned_at": "2026-04-20T16:00:00Z", "findings": <N>, "channel": "pvr|public-pr|skipped"}
```

### A7. Write local report

Save to `output/articles/vuln-scan-${today}.md` with sections for: repo metadata, scanner sources (ok/fail per tool), candidate count, confirmed findings with severity and channel, dedup note. Do **not** include exploit details for findings disclosed via PVR — redact file/line and link to the advisory ID instead.

### A8. Notify

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>, osv=<ok|fail>.
```

If the audit was clean:
```
*Vuln Scanner — <repo>*
Clean audit. <M> candidates reviewed, 0 confirmed. Scanners: semgrep=ok, trufflehog=ok, osv=ok.
```

Then log per the **Log** section below with `Mode: scan`.

---

## Arm B — RE-SUBMIT (PVR watchlist probe)

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.

### B1. Load the watchlist

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 empty
```
Log it and stop. No notification needed.

### B2. Probe each entry for PVR status

For each repo, run:

```bash
REPO="owner/repo"
gh api "repos/${REPO}/private-vulnerability-reporting" --jq '.enabled' 2>&1
```

Expected 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:
```bash
curl -s -H "Authorization: Bearer $GH_GLOBAL" \
  "https://api.github.com/repos/${REPO}/private-vulnerability-reporting" | grep -o '"enabled":[a-z]*'
```

### B3. Handle PVR-enabled flips

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 `-`).

```bash
SLUG=$(echo "$REPO" | tr '/' '-')
ls memory/pending-disclosures/${SLUG}*.md 2>/dev/null
```

**b) 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 `403`s on any repo you
don't own). Classic `repo` scope is sufficient.

```bash
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 line
- `description` → full advisory body
- `severity` → from `**Severity:**` field
- `cwe_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.

### B4. Update the watchlist file

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 `pvr-triage` from there).

### B5. Decide whether to notify

- **All entries still `pvr-disabled`:** no notification. Log counts and stop.
- **Any status flip detected (pvr-enabled, not-found, access-denied, submitted):** send notification.
- **Any `pvr-enabled-needs-reresearch`:** send urgent notification — window may be closing.

### B6. Format notification

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.md
```

If 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`.

### Watchlist file format

`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:

```markdown
# 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 |
```

---

## Arm C — DISCLOSE (auto-send armed email disclosures)

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.

### Eligibility — a draft is queued ONLY if ALL of these hold

A `.md` file in `memory/pending-disclosures/` is eligible iff:

1. **Armed:** frontmatter `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).
2. **Out-of-band email draft:** has a frontmatter `contact_email:` that matches a
   plausible email (`^[^@\s]+@[^@\s]+\.[^@\s]+$`).
3. **Still pending:** `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`) → **skip**. (`email-failed` means
   the sender gave up after repeated failures — leave it for the operator.)
4. **Sendable body present:** the email body can be cleanly isolated (see step C3).
5. **Not already sent:** no row in `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.

### C1. Load the queue and the sent-ledger

```bash
ls memory/pending-disclosures/*.md 2>/dev/null
jq -c '.[]' memory/email-log.json 2>/dev/null   # [] if absent — seed it as [] if missing
```

If `memory/pending-disclosures/` is empty → `DISCLOSURE_EMAILER_SKIP: queue empty`, stop.

### C2. Parse + filter each draft

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`.

### C3. Extract the sendable subject + body + cc

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.

### C4. Prioritize, then send in-run (fail-closed)

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):**
1. **Kill-switch.** `$DISCLOSURE_EMAIL_PAUSED` in `1/true/yes/on` → log `DISCLOSURE_EMAILER_SKIP: paused`, stop.
2. **Config.** Presence-check with the `${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).
3. **Budget.** Seed `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.
6. **Cooldown.** If this `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.
7. **Secret tripwire.** If subject+body match `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.
8. **Build cc** = the draft's `cc` (array or comma-string) + `$RESEND_CC` (operator audit copy), minus blanks and the `to`, deduped (`jq`).
9. **Build payload + send.** Build the JSON body with `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:
   ```bash
   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"
   ```
   Print `http=<code>`. Body has `.id` ⇒ **sent**; no `.id` / non-2xx ⇒ **failed**.
10. **On send:** append `{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`.
11. **On failure:** bump the draft's `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.

### C5. Notify + log

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`.

### Draft format (what Arm A emits for an auto-sendable email draft)

```markdown
---
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 -->
```

---

## Log

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
```

**Mode: scan** — add:
```
- Target: owner/repo (stars, language)
- Candidates: N | Confirmed: M
- Channels used: PVR (x), public PR (y), skipped (z)
- Scanner status: semgrep=ok trufflehog=ok osv=ok
- 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>)
```

## Network note

**Arm A (scan).** Getting the scanners to run under GitHub Actions takes **two** things:

1. **Install** — the binaries (`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` / `curl | sh` / `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.
2. **Execute** — non-interactive `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**.

**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 `curl` 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}` pl

…(truncated)
