fix(security): notify-only update check + interactive upgrade flow (#21)

## Summary

Hardens the AgentKey skill's security posture so ClawHub's `Suspicious`
flags can be cleared, and rebuilds the update UX into a two-tier design
modeled after [gstack](https://github.com/garrytan/gstack):

- **Tier 1 (`check-update.sh`):** scanner-safe, notify-only detection.
Single `GET https://api.github.com/...`, prints `UP_TO_DATE` /
`UPGRADE_AVAILABLE <old> <new>` / silent. No `git`, no `execve`, no
in-place writes outside `${TMPDIR}` cache.
- **Tier 2 (`SKILL.md` Step 0):** interactive upgrade flow. Agent
surfaces `AskUserQuestion` with **Yes / Always / Not now / Never** and
runs the actual update via `npx skills update` only after explicit
consent (or a previously persisted opt-in).

## What's in the PR

### 1. `check-update.sh` — notify-only with snooze + disable

Replaces the previous self-applying `git fetch` + `git checkout` with a
pure detection script. New behavior:

- Issues a single `GET` to `api.github.com`, validates the response with
a regex, compares against local `version.txt`.
- Two-tier cache TTL (60 min for `UP_TO_DATE`, 12 h for
`UPGRADE_AVAILABLE`).
- Honors `~/.config/agentkey/update-disabled` (silent if present).
- Honors `~/.config/agentkey/update-snoozed` (escalating 24h / 48h / 7d
backoff per `<version> <level> <epoch>` format; new remote version
invalidates the snooze).
- Cache invalidates automatically when local moves past cached `<old>`
(handles the user-updated-manually case).

### 2. `SKILL.md` Step 0 — interactive upgrade flow

When `check-update.sh` outputs `UPGRADE_AVAILABLE <old> <new>`, the
agent now:

1. Checks `AGENTKEY_AUTO_UPGRADE=1` env var or
`~/.config/agentkey/auto-upgrade` file. If either is set: announce
"Auto-upgrading…" and run the upgrade silently.
2. Otherwise, surface `AskUserQuestion`:
- **Yes, upgrade now** → run `npx skills update chainbase-labs/agentkey`
- **Always keep me up to date** → touch
`~/.config/agentkey/auto-upgrade`, then upgrade
- **Not now** → write `~/.config/agentkey/update-snoozed` with
escalating level
   - **Never ask again** → touch `~/.config/agentkey/update-disabled`

### 3. `check-mcp.sh` — shell-injection hardening

Replace shell-interpolated `'$HOME/.claude.json'` inside the `python3
-c` block with `os.path.expanduser('~/.claude.json')`. Removes a latent
injection vector when `$HOME` contains quote characters. Also tighten
`except:` → `except Exception:`.

### 4. `SECURITY.md` — Security Posture + scanner notes

- Documents what each script reads/writes, byte-precise
- Lists the three new config files under `~/.config/agentkey/` and which
side reads/writes each
- Network egress (one GET to `api.github.com` from the script; `npx` to
npm registry only via user-consented agent action)
- Credential handling (key never leaves the machine except as
Authorization header to AgentKey's own API)
- Scanner false-positive notes explaining why `check-update.sh`
(notify-only GitHub call) and `check-mcp.sh` (credential-pattern read
for status check) may match heuristics, and why both patterns are
intentional and bounded

## Threat-model framing

Scanners scanning the repo source see:
- `check-update.sh` — pure detection. One outbound GET. No `git`, no
`execve`. No "remote-controlled binary update" pattern any more.
- `check-mcp.sh` — local read of two known config paths to verify the
API key is configured. Output is a status code; key value discarded. No
exfiltration path.
- `SKILL.md` — text instructions for the agent (not executable code)
describing the interactive flow.

The actual `npx skills update` invocation lives in the agent's runtime,
gated by either explicit user consent (`AskUserQuestion`) or a
previously persisted opt-in flag the user set themselves. This is the
same threat model as gstack and any other Claude Code skill that
performs interactive operations.

## What is NOT in this PR (and why)

- **Pinning `@agentkey/mcp@^1` in manual-install JSON** — drafted and
reverted. `^1` only blocks major-version supply-chain attacks;
minor/patch attacks within `1.x` still go through, and the doc-snippet
pin doesn't reach the `--auth-login` writer (lives in the server repo).
Cost (manual installers must re-edit JSON for patches) outweighed the
marginal protection.

## Test plan

- [x] `bash -n` passes on both shell scripts
- [x] `check-update.sh` end-to-end tested:
  - `UP_TO_DATE` when versions match
  - `UPGRADE_AVAILABLE 0.9.0 1.2.0` when local lags
- Cache hit replays, then auto-invalidates when local moves past cached
old
  - **Snoozed within window** → silent
  - **Snooze expired (>24h)** → re-emits
- **Snoozed for old remote version** → re-emits (new version invalidates
snooze)
  - **`update-disabled` present** → silent regardless
- [x] Snooze escalation logic (level 1 → 2 → 3 → 3 capped) tested
standalone
- [x] Auto-upgrade detection (env var, config file, neither) tested
standalone
- [x] Python `os.path.expanduser` block runs cleanly against an existing
`~/.claude.json`
- [ ] After merge + release-please tag, run `clawhub skill publish` so
the new SECURITY.md and scripts ship in the published bundle, then
`clawhub skill rescan agentkey`
- [ ] If ClawScan / VirusTotal still flag after rescan, file an appeal
at [openclaw/clawhub](https://github.com/openclaw/clawhub) linking to
SECURITY.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
不白
2026-05-01 21:31:29 +08:00
committed by GitHub
parent db6b46c5f0
commit a05efd565f
4 changed files with 187 additions and 37 deletions
+52
View File
@@ -25,3 +25,55 @@ Pre-1.0 releases are no longer maintained. Please upgrade to the latest 1.x rele
## Disclosure
We follow coordinated disclosure. Once a fix is available, we publish a security advisory via GitHub Security Advisories and credit the reporter (with permission).
## Security Posture
### What this skill does on your machine
The skill ships two helper scripts that the agent invokes:
- **`skills/agentkey/scripts/check-update.sh`** — **notify-only**. At most every 60 minutes (12 hours once an upgrade is known), it calls `https://api.github.com/repos/chainbase-labs/agentkey/releases/latest`, compares the tag against the local `version.txt`, and prints `UPGRADE_AVAILABLE <old> <new>` if they differ. The script also honors a snooze file (`~/.config/agentkey/update-snoozed`, escalating 24h/48h/7d backoff) and a disable file (`~/.config/agentkey/update-disabled`); both are read-only from this script's perspective. The script never runs `git`, never writes to anything except its TMPDIR cache, and never executes downloaded code.
When the agent sees `UPGRADE_AVAILABLE` it surfaces an `AskUserQuestion` prompt (Yes / Always / Not now / Never). The actual update — `npx skills update chainbase-labs/agentkey` — runs only after the user picks "Yes" or "Always", or if the user has previously opted into auto-upgrade via `AGENTKEY_AUTO_UPGRADE=1` or `~/.config/agentkey/auto-upgrade`. The agent invokes that command via its own Bash tool, not via this script.
- **`skills/agentkey/scripts/check-mcp.sh`** — reads `~/.claude.json` and `~/.env.local` to verify the AgentKey MCP server is registered and the API key is present. **Read-only**; no network egress; output is a single status code.
### Files the skill reads or writes
| Path | Mode | Purpose |
|---|---|---|
| `~/.claude.json` | read | Detect MCP registration; read `AGENTKEY_API_KEY` env value |
| `~/.env.local` | read | Fallback location for `AGENTKEY_API_KEY` |
| `${TMPDIR}/agentkey-update-check` | read/write | Cache for the update check |
| `~/.config/agentkey/auto-upgrade` | written by the agent on user's "Always keep me up to date" choice; read by Step 0 to skip the prompt | Persistent auto-upgrade opt-in |
| `~/.config/agentkey/update-snoozed` | written by the agent on user's "Not now" choice; read by `check-update.sh` to suppress reminders | Snooze state (`<version> <level> <epoch>`) |
| `~/.config/agentkey/update-disabled` | written by the agent on user's "Never ask again" choice; read by `check-update.sh` to exit silently | Permanent disable for update checks |
| `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) / `%APPDATA%/Claude/...` (Windows) | written by the separate `npx -y @agentkey/mcp --auth-login` command, **not** by the skill | MCP registration |
| `~/.cursor/mcp.json` | written by `--auth-login`, **not** by the skill | MCP registration |
### Network egress from the skill
| Destination | When | Why |
|---|---|---|
| `api.github.com` | At most every 24 hours | Look up the latest release tag |
| npm registry | When the user first runs `npx -y @agentkey/mcp` | Resolve and run the MCP server |
### Credential handling
- `AGENTKEY_API_KEY` is stored only in user-local config files (paths above).
- The key leaves the user's machine only as the `Authorization` header to AgentKey's own API endpoints.
- The skill collects no telemetry.
### Supply chain
- Releases are cut by [release-please](https://github.com/googleapis/release-please) from merged Conventional-Commit PRs on `main` — no manual artifact uploads, no manual tag pushes.
- The companion `@agentkey/mcp` npm package is published from the same organization. Users invoke it via `npx -y @agentkey/mcp`, which resolves to the latest published version at runtime — this is the same threat model as any other `npx`-launched CLI.
- Future work: SLSA provenance attestation via GitHub OIDC + sigstore; signed npm provenance.
## Scanner false-positive notes
Automated scanners (VirusTotal, ClawScan) may flag this skill as `Suspicious` due to two intentional patterns. We document them here so reviewers can verify intent:
1. **`check-update.sh` contacts GitHub.** Pattern may match "remote-controlled binary update" heuristics. **Why this is intentional:** the script is notify-only — it issues a single `GET https://api.github.com/repos/chainbase-labs/agentkey/releases/latest`, compares the tag against `version.txt`, prints a one-line status, and exits. It never writes anywhere except the cache file at `${TMPDIR}/agentkey-update-check`, never invokes `git`, and never executes downloaded code. Update execution lives entirely in the agent's interactive layer (`AskUserQuestion` → `npx skills update`), gated by explicit user consent or a previously persisted opt-in flag.
2. **`check-mcp.sh` reads `*API_KEY*` env values.** Pattern matches "credential harvesting" heuristics. **Why this is intentional:** the read is local-only, never transmitted, and exists purely to confirm `AGENTKEY_API_KEY` is configured before the agent attempts an MCP call. The script's only output is a one-word status code (`MCP_OK` / `MCP_NO_KEY` / `MCP_NOT_CONFIGURED`); the key value itself is discarded.
If you operate a scanner and need additional context to triage, please email `support@chainbase.com`.
+50 -4
View File
@@ -10,16 +10,62 @@ version: 1.0.0
**Step 0 (always run first):**
1. Run the auto-update check silently (cached 24h — repeat calls are <10ms):
1. Run the version check silently (cached — repeat calls are <10ms):
```bash
bash "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/skills/agentkey/scripts/check-update.sh" 2>/dev/null
```
- `UPDATED: vX.Y.Z` → Tell the user once: "✓ AgentKey Skill updated to vX.Y.Z."
- `UPDATE_FAILED: ...` → Show the message verbatim to the user.
- `UP_TO_DATE` or empty → continue silently.
- `UP_TO_DATE` or empty → continue silently to step 2.
- `UPGRADE_AVAILABLE <old> <new>` → run the **Upgrade flow** below, then continue to step 2.
2. Confirm the 4 MCP tools — `list_tools`, `find_tools`, `describe_tool`, `execute_tool` — are visible in the current toolset. If **any** are missing → **Setup** (regardless of what the user asked). Do not attempt Query without all 4.
### Upgrade flow
Triggered when `check-update.sh` outputs `UPGRADE_AVAILABLE <old> <new>`. Substitute `<old>` and `<new>` with the actual versions parsed from that line.
**Step A — Check for auto-upgrade opt-in.** Run:
```bash
if [ "${AGENTKEY_AUTO_UPGRADE:-0}" = "1" ] || [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/agentkey/auto-upgrade" ]; then echo AUTO=1; fi
```
If the output is `AUTO=1`: tell the user once "Auto-upgrading AgentKey v\<old\> → v\<new\>…", run **Step C**, then continue to step 2. **Do not** show the AskUserQuestion prompt.
**Step B — Otherwise, prompt the user with AskUserQuestion:**
- Question: `AgentKey v<new> is available (currently on v<old>). Upgrade now?`
- Options:
- **`Yes, upgrade now`** → run **Step C**.
- **`Always keep me up to date`** → run:
```bash
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/agentkey" && touch "${XDG_CONFIG_HOME:-$HOME/.config}/agentkey/auto-upgrade"
```
Tell the user "Auto-upgrade enabled — future AgentKey updates install automatically. Remove `~/.config/agentkey/auto-upgrade` to undo." Then run **Step C**.
- **`Not now`** → run:
```bash
_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/agentkey"
_SNOOZE="$_CFG/update-snoozed"
_NEW="<new>"
_LEVEL=0
if [ -f "$_SNOOZE" ]; then
_SVER=$(awk '{print $1}' "$_SNOOZE" 2>/dev/null)
[ "$_SVER" = "$_NEW" ] && _LEVEL=$(awk '{print $2}' "$_SNOOZE" 2>/dev/null)
case "$_LEVEL" in *[!0-9]*) _LEVEL=0 ;; esac
fi
_LEVEL=$((_LEVEL + 1)); [ "$_LEVEL" -gt 3 ] && _LEVEL=3
mkdir -p "$_CFG" && echo "$_NEW $_LEVEL $(date +%s)" > "$_SNOOZE"
echo "SNOOZED_LEVEL=$_LEVEL"
```
Translate the level into a duration for the user — `SNOOZED_LEVEL=1` → "Next reminder in 24h", `2` → "in 48h", `3` → "in 1 week". Continue to step 2 — **do not** upgrade.
- **`Never ask again`** → run:
```bash
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/agentkey" && touch "${XDG_CONFIG_HOME:-$HOME/.config}/agentkey/update-disabled"
```
Tell the user "Update checks disabled. Remove `~/.config/agentkey/update-disabled` to re-enable." Continue to step 2 — **do not** upgrade.
**Step C — Run the upgrade.** Invoke:
```bash
npx skills update chainbase-labs/agentkey
```
On success: tell the user "✓ AgentKey updated to v\<new\>." On failure: show the failure verbatim and tell the user "Run `npx skills update chainbase-labs/agentkey` manually to retry." Either way, continue to step 2.
Then route by intent:
- "setup"/"install"/"api key"/"reinstall" → **Setup**
- "status"/"diagnose" → **Status**
+4 -3
View File
@@ -14,11 +14,12 @@ check_key_exists() {
if [ -f "$HOME/.claude.json" ]; then
local key_val
key_val=$(python3 -c "
import json, sys
import json, os
try:
d = json.load(open('$HOME/.claude.json'))
with open(os.path.expanduser('~/.claude.json')) as f:
d = json.load(f)
print(d.get('mcpServers', {}).get('agentkey', {}).get('env', {}).get('AGENTKEY_API_KEY', ''))
except: pass
except Exception: pass
" 2>/dev/null | tr -d '[:space:]')
[ -n "$key_val" ] && return 0
fi
+81 -30
View File
@@ -1,70 +1,121 @@
#!/bin/bash
# AgentKey — Auto-update to latest GitHub Release.
# Result cached in TMPDIR to keep repeat skill invocations fast.
# Outputs a single line: UP_TO_DATE | UPDATED: vX.Y.Z | UPDATE_FAILED: <reason>
# AgentKey — Notify when a newer release is available on GitHub.
# Notify-only: this script never modifies the install. It tells the agent
# there's a new version; the agent surfaces a prompt and (with the user's
# consent) invokes the upgrade.
#
# Result cached in TMPDIR for fast repeat invocations. Persistent state
# (snooze, disable, auto-upgrade flag) lives under ~/.config/agentkey/.
#
# Outputs a single line, or nothing:
# UP_TO_DATE — local matches latest release
# UPGRADE_AVAILABLE <old> <new> — local differs from latest release
# AND not currently snoozed/disabled
# (empty / silent) — disabled, snoozed, no version file,
# network down, or unexpected response
REPO="chainbase-labs/agentkey"
CACHE_TTL_SUCCESS=86400 # 24h for UP_TO_DATE
CACHE_TTL_FAILURE=3600 # 1h for UPDATE_FAILED (retry sooner)
CACHE_TTL_UP_TO_DATE=3600 # 60 min — detect new releases quickly
CACHE_TTL_UPGRADE=43200 # 12 h — keep nagging once an upgrade is known
CURL_TIMEOUT=3
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)}"
VERSION_FILE="$PLUGIN_ROOT/version.txt"
CACHE_FILE="${TMPDIR:-/tmp}/agentkey-update-check"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/agentkey"
DISABLED_FILE="$CONFIG_DIR/update-disabled"
SNOOZE_FILE="$CONFIG_DIR/update-snoozed"
# Disabled by user ("Never ask again") — exit silently.
if [ -f "$DISABLED_FILE" ]; then
exit 0
fi
LOCAL_VERSION=$(tr -d '[:space:]' < "$VERSION_FILE" 2>/dev/null)
if [ -z "$LOCAL_VERSION" ]; then
echo "UP_TO_DATE"
exit 0
fi
# check_snooze <remote_version> → returns 0 (snoozed) or 1 (not snoozed).
# Snooze file format: "<version> <level> <epoch>" where level 1=24h, 2=48h, 3+=7d.
# A new remote version invalidates the snooze.
check_snooze() {
local remote_ver="$1"
[ -f "$SNOOZE_FILE" ] || return 1
local sver slevel sepoch
sver=$(awk '{print $1}' "$SNOOZE_FILE" 2>/dev/null)
slevel=$(awk '{print $2}' "$SNOOZE_FILE" 2>/dev/null)
sepoch=$(awk '{print $3}' "$SNOOZE_FILE" 2>/dev/null)
[ -n "$sver" ] && [ -n "$slevel" ] && [ -n "$sepoch" ] || return 1
case "$slevel" in *[!0-9]*) return 1 ;; esac
case "$sepoch" in *[!0-9]*) return 1 ;; esac
[ "$sver" = "$remote_ver" ] || return 1
local duration
case "$slevel" in
1) duration=86400 ;;
2) duration=172800 ;;
*) duration=604800 ;;
esac
local now
now=$(date +%s)
[ $((sepoch + duration)) -gt "$now" ]
}
# Fast path: recent cache hit — avoids the GitHub API round-trip (~1.5s).
if [ -f "$CACHE_FILE" ]; then
MTIME=$(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0)
AGE=$(( $(date +%s) - MTIME ))
case "$(head -1 "$CACHE_FILE" 2>/dev/null)" in
"UPDATE_FAILED:"*) TTL=$CACHE_TTL_FAILURE ;;
*) TTL=$CACHE_TTL_SUCCESS ;;
CACHED=$(head -1 "$CACHE_FILE" 2>/dev/null || true)
case "$CACHED" in
"UP_TO_DATE") TTL=$CACHE_TTL_UP_TO_DATE ;;
"UPGRADE_AVAILABLE "*) TTL=$CACHE_TTL_UPGRADE ;;
*) TTL=0 ;;
esac
if [ "$AGE" -ge 0 ] && [ "$AGE" -lt "$TTL" ]; then
cat "$CACHE_FILE"
exit 0
case "$CACHED" in
"UP_TO_DATE")
echo "UP_TO_DATE"
exit 0
;;
"UPGRADE_AVAILABLE "*)
CACHED_OLD=$(echo "$CACHED" | awk '{print $2}')
if [ "$CACHED_OLD" = "$LOCAL_VERSION" ]; then
CACHED_NEW=$(echo "$CACHED" | awk '{print $3}')
if check_snooze "$CACHED_NEW"; then
exit 0
fi
echo "$CACHED"
exit 0
fi
# Local moved on — fall through to re-check.
;;
esac
fi
fi
# Remote check — fetch latest release tag.
# Slow path: fetch latest release tag from GitHub.
LATEST_TAG=$(curl -sf --max-time "$CURL_TIMEOUT" \
"https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \
| grep -m1 '"tag_name"' \
| sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
LATEST_VERSION=${LATEST_TAG#[vV]}
# Network failure — stay silent; skip caching so we retry on the next call.
if [ -z "$LATEST_VERSION" ]; then
echo "UP_TO_DATE"
# Validate response looks like a version number — rejects HTML error pages,
# rate-limit JSON, and other surprises that slipped past curl -f.
if ! echo "$LATEST_VERSION" | grep -qE '^[0-9]+\.[0-9.]+$'; then
exit 0
fi
# Already current.
if [ "$LOCAL_VERSION" = "$LATEST_VERSION" ]; then
echo "UP_TO_DATE" > "$CACHE_FILE" 2>/dev/null
echo "UP_TO_DATE"
exit 0
fi
# Newer version available — attempt git auto-update.
# Shallow-fetch only the target tag (not all tags) for speed.
if [ -d "$PLUGIN_ROOT/.git" ]; then
if git -C "$PLUGIN_ROOT" fetch --quiet --depth=1 origin \
"+refs/tags/$LATEST_TAG:refs/tags/$LATEST_TAG" 2>/dev/null \
&& git -C "$PLUGIN_ROOT" checkout --quiet "$LATEST_TAG" 2>/dev/null; then
# After a successful checkout, subsequent checks are UP_TO_DATE.
echo "UP_TO_DATE" > "$CACHE_FILE" 2>/dev/null
echo "UPDATED: v$LATEST_VERSION"
exit 0
fi
fi
MSG="UPDATE_FAILED: Run \`/plugin update agentkey\` to update to v$LATEST_VERSION"
# Newer version available — cache the result, then suppress output if snoozed.
MSG="UPGRADE_AVAILABLE $LOCAL_VERSION $LATEST_VERSION"
echo "$MSG" > "$CACHE_FILE" 2>/dev/null
if check_snooze "$LATEST_VERSION"; then
exit 0
fi
echo "$MSG"