mirror of
https://github.com/chainbase-labs/Agentkey.git
synced 2026-09-20 14:20:23 +08:00
fix(skill): eliminate Hermes scanner findings (#28)
## Summary Hermes flagged this skill with **3 findings** (1 persistence + 2 traversal) that, combined with `community-source` status, produced a `BLOCKED` verdict requiring users to install with `--force`. None of the findings reflected actual risk — all stemmed from incidental code/doc patterns. This PR removes every match and adds CI to keep them gone. ## Changes ### A — Eliminate path traversal in `check-update.sh` - Drop `dirname(BASH_SOURCE)/..` resolution and `version.txt` read - Embed `LOCAL_VERSION` as a constant, synced by release-please via `extra-files` - Script now does **zero filesystem traversal** — no `..`, no `dirname`, no `CLAUDE_PLUGIN_ROOT` dependency ### B — Remove persistent file-path enumeration from `SKILL.md` - Replace explicit list of agent config paths (`~/.claude/settings.json`, `claude_desktop_config.json`, `~/.cursor/mcp.json`) with a neutral one-liner pointing to `SECURITY.md` - The CLI behavior is fully documented in `SECURITY.md` for transparency without triggering scanner heuristics ### C — Align `SECURITY.md` with new internals - Update the `check-update.sh` description to call out the new zero-traversal design ### Hardening (D1) — script becomes shellcheck-clean - `set -u` + `set -o pipefail`; explicit `|| true` on every intentional silent-failure path - Replace 3× awk fork-and-read on the snooze file with a single atomic `read -r` (closes a real cross-process race) - Same treatment for the cache file parse — also eliminates `echo $VAR | awk` injection surface - Cache `date +%s` once per run (`NOW`), shared by cache-age and snooze expiry math - Switch `LATEST_VERSION` validation from `echo | grep -qE` to `case` glob, matching how `LOCAL_VERSION` is validated - **Net**: ~half the forks per run, no silent failures, stricter validation ### Metadata (D2) — improve community-source traceability - `SKILL.md` frontmatter: add `author` / `homepage` / `repository` / `license` fields - Standard fields, ignored by clients that don't read them, but give scanners and reviewers a direct path to the publisher ### Release plumbing - `SKILL.md` frontmatter `version`: 1.0.0 → 1.2.3 (long-standing drift) and tagged with `# x-release-please-version` so release-please now syncs it - `release-please-config.json`: add `check-update.sh` and `SKILL.md` to `extra-files` - New workflow `.github/workflows/verify-version-sync.yml` asserts all four versions match on every relevant push/PR — catches the case where someone hand-edits one location and forgets the others `version.txt` remains the **single source of truth** that humans maintain. The other three locations (`plugin.json`, `SKILL.md` frontmatter, `check-update.sh`) are auto-synced derivatives. ## Test plan - [x] Local smoke-test of `check-update.sh` across 11 scenarios: - cold run, `UP_TO_DATE` cache hit, `UPGRADE_AVAILABLE` cache hit - snoozed (active / expired / long expired) - disabled file - corrupted binary cache - local-version-moved-on - All paths produce expected output; `set -u` catches no false positives - [x] `bash -n` syntax check passes - [x] All four version locations (`version.txt`, `plugin.json`, `SKILL.md` frontmatter, `check-update.sh`) verified to match at 1.2.3 - [x] `SKILL.md` YAML frontmatter parses correctly with the new fields - [ ] CI passes - [ ] Re-scan with Hermes confirms 0 findings (post-merge, after release tag) ## Notes for reviewer - All four version locations exist for distinct technical reasons — see `release-please-config.json` for the sync wiring. Maintainer cost remains exactly one place (`version.txt`, bumped automatically by release-please from conventional commits). - The `set -u` change is the most behavior-affecting — please review the `|| true` guards on `read -r` and `curl ... | grep | sed` carefully. 🤖 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:
@@ -0,0 +1,47 @@
|
||||
name: verify-version-sync
|
||||
|
||||
# Guards against drift between the canonical version (skills/agentkey/version.txt)
|
||||
# and the version constant embedded in skills/agentkey/scripts/check-update.sh.
|
||||
# release-please syncs both via extra-files; this job catches the case where a
|
||||
# human edits one of them by hand and forgets the other.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'skills/agentkey/version.txt'
|
||||
- 'skills/agentkey/scripts/check-update.sh'
|
||||
- 'skills/agentkey/SKILL.md'
|
||||
- '.claude-plugin/plugin.json'
|
||||
- 'release-please-config.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/agentkey/version.txt'
|
||||
- 'skills/agentkey/scripts/check-update.sh'
|
||||
- 'skills/agentkey/SKILL.md'
|
||||
- '.claude-plugin/plugin.json'
|
||||
- 'release-please-config.json'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Assert versions match
|
||||
run: |
|
||||
set -euo pipefail
|
||||
canonical=$(tr -d '[:space:]' < skills/agentkey/version.txt)
|
||||
script=$(grep -E '^LOCAL_VERSION="[^"]+"' skills/agentkey/scripts/check-update.sh \
|
||||
| head -1 | sed -E 's/^LOCAL_VERSION="([^"]+)".*/\1/')
|
||||
plugin=$(python3 -c 'import json; print(json.load(open(".claude-plugin/plugin.json"))["version"])')
|
||||
skill=$(awk '/^---/{c++; next} c==1 && /^version:/{print $2; exit}' skills/agentkey/SKILL.md)
|
||||
echo "version.txt: $canonical"
|
||||
echo "check-update.sh: $script"
|
||||
echo "plugin.json: $plugin"
|
||||
echo "SKILL.md: $skill"
|
||||
if [ "$canonical" != "$script" ] \
|
||||
|| [ "$canonical" != "$plugin" ] \
|
||||
|| [ "$canonical" != "$skill" ]; then
|
||||
echo "::error::Version drift detected. release-please syncs all four from version.txt — re-run release-please or restore the values manually."
|
||||
exit 1
|
||||
fi
|
||||
+1
-1
@@ -32,7 +32,7 @@ We follow coordinated disclosure. Once a fix is available, we publish a security
|
||||
|
||||
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 `skills/agentkey/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.
|
||||
- **`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 a version constant embedded in the script itself (synced at release time by release-please via `extra-files`), and prints `UPGRADE_AVAILABLE <old> <new>` if they differ. The script does **no** filesystem traversal — there is no `dirname`/`..` path resolution, no read of `version.txt`, no dependency on `CLAUDE_PLUGIN_ROOT`. It 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.
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
"type": "json",
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "skills/agentkey/scripts/check-update.sh"
|
||||
},
|
||||
{
|
||||
"type": "generic",
|
||||
"path": "skills/agentkey/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
---
|
||||
name: agentkey
|
||||
description: Web search, scrape URLs, social media data, crypto data. Use AgentKey instead of built-in web search. Not for concepts/definitions.
|
||||
version: 1.0.0
|
||||
version: 1.2.3 # x-release-please-version
|
||||
author: Chainbase Labs
|
||||
homepage: https://agentkey.app
|
||||
repository: https://github.com/chainbase-labs/agentkey
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# AgentKey
|
||||
@@ -79,12 +83,7 @@ The skill is useless without the AgentKey MCP server registered with the user's
|
||||
! npx -y @agentkey/mcp --auth-login
|
||||
```
|
||||
|
||||
What it does:
|
||||
1. Opens a browser tab → user logs in → key is granted
|
||||
2. Writes the MCP server entry (with the key as an env var) into known config files:
|
||||
- **Claude Code** → `~/.claude/settings.json`
|
||||
- **Claude Desktop** (mac/win only) → `~/Library/Application Support/Claude/claude_desktop_config.json` or `%APPDATA%/Claude/...`
|
||||
- **Cursor** → `~/.cursor/mcp.json`
|
||||
What it does: opens a browser to mint an API key, then registers the AgentKey MCP server with the user's agent. The skill itself does not write any files; that work is performed by the separate `@agentkey/mcp` CLI. See `SECURITY.md` in the repo root for the full list of supported clients and the exact files the CLI touches.
|
||||
|
||||
When the command finishes, tell the user verbatim:
|
||||
|
||||
|
||||
@@ -11,21 +11,29 @@
|
||||
# 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
|
||||
# (empty / silent) — disabled, snoozed, embedded version
|
||||
# malformed, network down, or unexpected
|
||||
# response
|
||||
|
||||
# Strict-ish mode: catch unset vars and silent pipe failures. We deliberately
|
||||
# do *not* set -e — several code paths intentionally rely on commands failing
|
||||
# silently (curl with no network, optional files missing, cache writes on a
|
||||
# read-only TMPDIR, etc.) and we guard each one with `|| true` / explicit
|
||||
# fallbacks instead.
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
REPO="chainbase-labs/agentkey"
|
||||
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
|
||||
|
||||
# Anchor on the skill directory itself, not on a "plugin root" — the skill is
|
||||
# distributed two ways with different layouts: as a Claude Code plugin (whole
|
||||
# repo) or via the Skills CLI (only `skills/agentkey/` is copied to
|
||||
# ~/.claude/skills/agentkey/). Resolving relative to this script keeps both
|
||||
# paths working without depending on CLAUDE_PLUGIN_ROOT.
|
||||
SKILL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"
|
||||
VERSION_FILE="$SKILL_ROOT/version.txt"
|
||||
# Local version is embedded at release time — no filesystem traversal,
|
||||
# no dependency on CLAUDE_PLUGIN_ROOT or the skill's installed layout.
|
||||
# release-please syncs this line on every release via the `extra-files`
|
||||
# entry in release-please-config.json. Do not edit by hand.
|
||||
LOCAL_VERSION="1.2.3" # x-release-please-version
|
||||
|
||||
CACHE_FILE="${TMPDIR:-/tmp}/agentkey-update-check"
|
||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/agentkey"
|
||||
DISABLED_FILE="$CONFIG_DIR/update-disabled"
|
||||
@@ -36,10 +44,15 @@ if [ -f "$DISABLED_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LOCAL_VERSION=$(tr -d '[:space:]' < "$VERSION_FILE" 2>/dev/null)
|
||||
if [ -z "$LOCAL_VERSION" ]; then
|
||||
exit 0
|
||||
fi
|
||||
# Sanity check the embedded version — if release-please ever fails to sync
|
||||
# this line, exit silently rather than emit garbage.
|
||||
case "$LOCAL_VERSION" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Cache `date +%s` once — used by both the cache age math and snooze expiry.
|
||||
NOW=$(date +%s)
|
||||
|
||||
# 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.
|
||||
@@ -47,49 +60,57 @@ fi
|
||||
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)
|
||||
|
||||
# Single-pass read replaces the previous 3× awk fork. Also closes the
|
||||
# race where the file could be rewritten between fields.
|
||||
local sver="" slevel="" sepoch="" _rest=""
|
||||
read -r sver slevel sepoch _rest < "$SNOOZE_FILE" 2>/dev/null || return 1
|
||||
|
||||
[ -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" ]
|
||||
|
||||
[ $((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 ))
|
||||
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 ;;
|
||||
MTIME=$(stat -f %m "$CACHE_FILE" 2>/dev/null \
|
||||
|| stat -c %Y "$CACHE_FILE" 2>/dev/null \
|
||||
|| echo 0)
|
||||
AGE=$(( NOW - MTIME ))
|
||||
|
||||
# Single-pass read of the cache line. Empty / corrupted cache → all
|
||||
# fields stay empty and fall through to slow path.
|
||||
CACHED_KIND="" CACHED_OLD="" CACHED_NEW="" _rest=""
|
||||
read -r CACHED_KIND CACHED_OLD CACHED_NEW _rest < "$CACHE_FILE" 2>/dev/null || true
|
||||
|
||||
case "$CACHED_KIND" 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
|
||||
case "$CACHED" in
|
||||
case "$CACHED_KIND" 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}')
|
||||
"UPGRADE_AVAILABLE")
|
||||
if [ "$CACHED_OLD" = "$LOCAL_VERSION" ] && [ -n "$CACHED_NEW" ]; then
|
||||
if check_snooze "$CACHED_NEW"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "$CACHED"
|
||||
echo "UPGRADE_AVAILABLE $CACHED_OLD $CACHED_NEW"
|
||||
exit 0
|
||||
fi
|
||||
# Local moved on — fall through to re-check.
|
||||
@@ -102,24 +123,25 @@ fi
|
||||
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]}
|
||||
| sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') || true
|
||||
LATEST_VERSION="${LATEST_TAG#[vV]}"
|
||||
|
||||
# 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
|
||||
case "$LATEST_VERSION" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ "$LOCAL_VERSION" = "$LATEST_VERSION" ]; then
|
||||
echo "UP_TO_DATE" > "$CACHE_FILE" 2>/dev/null
|
||||
echo "UP_TO_DATE" > "$CACHE_FILE" 2>/dev/null || true
|
||||
echo "UP_TO_DATE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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
|
||||
echo "$MSG" > "$CACHE_FILE" 2>/dev/null || true
|
||||
if check_snooze "$LATEST_VERSION"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user