# Watch alerts: package advisories and webhooks

KarmaDue watches the tools on your agent's watchlist and tells your person when something material changes. Since 2026-11-12 (fix98) this also covers **npm and PyPI packages that are not in the KarmaDue catalog**, and alerts can be **POSTed to your own HTTPS endpoint**, signed. Webhooks are off until you turn them on.

Nothing here changes what `check`, `check_install` or the preflight API answer. Watch alerts only add information.

## What gets an alert

| Watch item | Example | Alerts on |
|---|---|---|
| Catalog tool | `kd:res:...`, a GitHub repo, a registry name, a remote MCP URL | New advisory, new owner, license change, archived upstream, changed MCP tools, changed terms (daily snapshot, as before) |
| Package, any version | `npm:left-pad`, `pypi:requests` | Any new OSV.dev advisory or malicious-package report (`MAL-…`) for the package |
| Package, exact version | `npm:lodash@4.17.15`, `pypi:requests==2.19.0` | New reports that OSV.dev says affect that version |
| Package, range | `npm:express@^4.18.0`, `pypi:django>=4.2,<5` | New reports whose affected versions overlap the range |

Notes:

- Use the `npm:` or `pypi:` prefix. `name@1.2.3` is read as npm. A bare name with no version is ambiguous and is not watched by the advisory feed.
- Sources, checked every minute: the OpenSSF malicious-packages repository (new MAL reports as they are merged), OSV.dev's change feeds for npm and PyPI (GHSA, PYSEC, MAL), and the GitHub Advisory Database (reviewed and malware advisories, every 2 and 4 minutes). The same advisory under two ids (for example GHSA and PYSEC) is sent once, under the GHSA id, and a package gets at most one malicious-package alert per watched item per 7 days, whichever source names it first.
- "New" means published within 7 days before you added the item or any time after, or first seen by KarmaDue after you added it. Advisories older than that are already shown by `get_watchlist`; they are not re-sent as alerts. Alerts older than 30 days are not sent.
- Only packages that changed in a feed are re-read, within the same minute. Every watched package is also fully re-read every hour as a backstop. At most 10 alerts per agent per day.
- Range matching uses OSV's version lists and ranges (semver for npm, PEP 440 for PyPI). An exact version is matched by OSV itself.

### How fast

Measured 2026-11-12 (times UTC):

| Step | Time |
|---|---|
| Report merged in OpenSSF malicious-packages → final MAL id assigned | about 1.5 minutes (their bot) |
| MAL id assigned → KarmaDue sees it | at most 60 seconds (polled every minute) |
| Feed run starts → alert created | 2.3 seconds (replay of MAL-2026-19118 and MAL-2026-19147 through the live one-minute job) |
| Alert created → your webhook answered 200 | 0.8 seconds (same replay; sent in the same run) |

So a malicious-package report merged into OpenSSF reaches your webhook in about 1.5 to 2.6 minutes (about 1 minute after its id is assigned, 30 seconds on average). Advisories that appear first in GitHub's database reach you within about 2 minutes (malware) or 4 minutes (reviewed) of GitHub publishing them; advisories that appear first in OSV.dev within about 4 to 5 minutes of OSV recording them, because OSV writes its public change file about 3 minutes after the record. Those two databases are slower for MAL reports: on 2026-11-12 OSV.dev listed three of them 8 to 15 minutes after their OpenSSF id was assigned (its public change file followed about 3 minutes later), and GitHub's malware advisory for @ruthless-imperium/baileys came an hour after OpenSSF. A report's `published` date is when the reporter first found it, which can be days before it is public anywhere; KarmaDue cannot alert before a report is public.

Each alert becomes a finding for your person with three choices (keep, pin a version that is not affected, remove), exactly like catalog alerts. Your agent sees it in `watchlist_changes` with `alert_kind: "package_advisory"`, the advisory id, whether it is a malicious-package report, the publish date, how it matched (for example "your range ^0.5.0 includes affected version 0.5.2") and the OSV.dev link.

## Webhooks (opt-in)

Three tools, authenticated exactly like `set_watchlist`: an unclaimed agent signs the call with its passport key (`x-kd-signature`, `x-kd-timestamp`, `x-kd-nonce`; text from `get_signing_payload`); a claimed agent's owner calls with their session. Setting a webhook needs a registered passport.

| Tool | Arguments | Does |
|---|---|---|
| `set_watch_webhook` | `agent_id`, `url`, optional `enabled`, `rotate_secret`, `remove` | Sets, changes, turns off (`enabled: false`), rotates the secret or removes the webhook. The first call and every `rotate_secret: true` return `signing_secret` **once**. |
| `get_watch_webhook` | `agent_id` | URL, on/off and why, last status, last 10 deliveries. Never returns the secret (only its last 4 characters). |
| `test_watch_webhook` | `agent_id` | Queues a signed `watch.test` event (at most 5 per hour). |

Call them as MCP `tools/call` on `https://ogogoizwsfaduzehkshb.supabase.co/functions/v1/mcp` with the same signature headers as `set_watchlist` (listed in `tools/list` on the `/full` profile since 2026-11-12), or through the RPC endpoint the MCP server itself uses (same checks, same signatures):

```bash
curl -s https://ogogoizwsfaduzehkshb.supabase.co/rest/v1/rpc/kd_mcp_tiered \
  -H 'apikey: sb_publishable_ysx86UcdsZzIOVF2_0jeXw_8kxuCInK' -H 'content-type: application/json' \
  -d '{"p_tool":"set_watch_webhook","p_args":{"agent_id":"<your agent id>","url":"https://hooks.example.com/kd",
       "_kd_auth":{"signature":"<base64url Ed25519 signature>","timestamp":"<unix seconds>","nonce":"<16-128 url-safe chars>"}}}'
```

Sign the text that `get_signing_payload` returns for `tool: "set_watch_webhook"` and the same `arguments` (without `_kd_auth`).

### URL rules

- `https://` only, default port 443, a public DNS host name. No IP-address URLs, no user name or password in the URL, no `localhost`, `.local`, `.internal` or similar names, no KarmaDue hosts.
- Before **every** send, KarmaDue resolves the host and refuses to send if any address is private, loopback, link-local, CGNAT, multicast or otherwise reserved (IPv4 and IPv6, including IPv4-mapped, NAT64 and 6to4 forms). A refused send turns the webhook off with the reason, visible in `get_watch_webhook`.
- Redirects are not followed (a 3xx counts as a failed attempt). Each attempt times out after 10 seconds.

### Delivery and retries

- A 2xx answer is success. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (6 attempts), then marked failed.
- A `410 Gone` answer turns the webhook off immediately. 20 failed deliveries in a row turn it off too. Turn it back on with `set_watch_webhook` and `enabled: true` (or a new `url`).
- Every delivery has a stable id. A retry sends the same body with the same id, so dedupe on `X-KD-Delivery`.
- Turning the webhook off cancels pending deliveries. KarmaDue sends no email.

### Request

```
POST <your url>
Content-Type: application/json
User-Agent: KarmaDue-Webhooks/1.0 (+https://karmadue.expo.app/docs/watch-alerts.md)
X-KD-Event: watch.alert            (or watch.test)
X-KD-Delivery: 3c8f093e-c22a-4979-b5f4-3b563f35a052
X-KD-Attempt: 1
X-KD-Timestamp: 1794468425
X-KD-Signature: t=1794468425,v1=5b0e...<64 hex chars>
```

```json
{
  "id": "3c8f093e-c22a-4979-b5f4-3b563f35a052",
  "created_at": "2026-11-12T07:27:25.36+00:00",
  "type": "watch.alert",
  "agent_id": "<your agent id>",
  "alert": {
    "alert_id": "a9e67f9e-e5ca-4057-926c-721c6f4c085f",
    "alert_kind": "package_advisory",
    "title": "Malicious package report: @rfewqa-fes/mnm-doc-mcp (npm)",
    "line": "Malicious-package report MAL-2026-19148 (OSV.dev, published 2026-11-12) for @rfewqa-fes/mnm-doc-mcp (npm): Malicious code in @rfewqa-fes/mnm-doc-mcp (npm). OSV.dev lists version 1.0.0 as affected.",
    "package": "npm:@rfewqa-fes/mnm-doc-mcp",
    "watched_input": "npm:@rfewqa-fes/mnm-doc-mcp@1.0.0",
    "advisory_id": "MAL-2026-19148",
    "malicious": true,
    "published": "2026-11-12T04:07:58+00:00",
    "source_url": "https://osv.dev/vulnerability/MAL-2026-19148",
    "choose_url": "https://karmadue.expo.app/watch/a9e67f9e-e5ca-4057-926c-721c6f4c085f",
    "claim_url": "https://karmadue.expo.app/findings/adopt?code=kdfind_..."
  }
}
```

Catalog alerts have `"alert_kind": "change"` with `resource_id`, `change_kind` (advisory, owner, license, archived, schema, terms), `line`, `source_url`, `page`, `choose_url` and `pin_version`. `claim_url` appears only while the agent is unclaimed: forward it to your person.

### Verify the signature

`v1` is the hex HMAC-SHA256 of `"<t>.<raw request body>"`, keyed with your signing secret. Use the raw bytes you received, before any JSON parsing. Reject if the HMAC does not match or `t` is more than 5 minutes from your clock.

```python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300
```

```js
import crypto from "node:crypto";
export function verify(secret, header, rawBody) {
  const p = Object.fromEntries(header.split(",").map((s) => s.split("=", 2)));
  const expected = crypto.createHmac("sha256", secret).update(`${p.t}.${rawBody}`).digest("hex");
  return expected.length === p.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1))
    && Math.abs(Date.now() / 1000 - Number(p.t)) < 300;
}
```

## Metrics

`kd_watch_metrics` (public RPC) now separates what can alert from what cannot:

- `material_changes_30d`: every material change in the whole catalog (kept for continuity; it is **not** an alert count).
- `material_changes_on_watched_tools_30d`: material changes on catalog tools that an active agent watches.
- `dispatch_candidates_7d`: alert candidates in the last 7 days (watched catalog changes plus matching new package advisories), sent or not.
- `unmatched_watch_items`: watch items that match no catalog tool, split into those the advisory feed covers (`npm:`/`pypi:`) and those that cannot alert.

## Limits

- Only npm and PyPI packages get advisory alerts. Other ecosystems, GitHub-only repos and MCP URLs that are not in the catalog do not.
- Advisory sources are OpenSSF malicious-packages, OSV.dev and the GitHub Advisory Database. An advisory that none of them has published will not alert. GitHub is read without a key, so when its hourly allowance runs out KarmaDue waits for the reset and relies on the other two.
- Advisories GitHub publishes before OSV.dev are matched from GitHub's own version ranges until OSV.dev has the record.
- A range is matched against OSV's affected versions and ranges; an unusual range syntax that cannot be parsed is not watched (see the item in `get_watchlist`).
- The DNS check runs before each send, but the HTTPS connection resolves the name again, so a host that changes its DNS answer between the two can in principle reach a different address. Private targets are still refused at the URL step and at the pre-send check.
- Secrets are stored server-side so KarmaDue can sign; rotate with `rotate_secret: true` if you think one leaked.
