feat(seo): account-management verbs (connect/accounts/forget) + connect.sh wrapper

tokenstore remove/clear, fetch.sh forget dispatch, and a connect.sh wrapper
that sources ~/.claude/.env internally and runs from any project. /seo now
routes connect|accounts|forget before the audit flow; Makefile seo-connect
delegates to the wrapper. Labels are guarded to shell-safe ASCII (POSIX case,
whole-string, C-locale) as defense-in-depth; forget output states local
removal is not a Google-side revocation.
This commit is contained in:
Bastien Chanot
2026-07-10 12:38:32 +02:00
parent 61a98d3ae1
commit 8bf7459566
7 changed files with 258 additions and 27 deletions
+2 -3
View File
@@ -25,9 +25,8 @@ onboard: link ## Onboard an existing project (run from the project directory)
seo-connect: ## Connect a Google account for /seo FULL (creates venv, OAuth consent)
@python3 -m venv "$$HOME/.claude/.venv-seo-data"
@"$$HOME/.claude/.venv-seo-data/bin/pip" install -q -r lib/seo-data/requirements.txt
@bash -c 'set -a; [ -f "$$HOME/.claude/.env" ] && . "$$HOME/.claude/.env"; set +a; \
read -r -p "Label for this account (e.g. client-a): " label; \
"$$HOME/.claude/.venv-seo-data/bin/python3" lib/seo-data/connect.py --label "$$label"'
@bash -c 'read -r -p "Label for this account (e.g. client-a): " label; \
bash lib/seo-data/connect.sh --label "$$label"'
test: ## Run deterministic tests (lib/tests/*.test.sh + lib/gitflow-test.sh + lib/tests/run-*.sh)
@fail=0; for t in lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh; do \
+23 -6
View File
@@ -18,14 +18,19 @@ is written to disk during an audit, only at `make seo-connect`.
One-time per Google account:
```bash
make seo-connect
make seo-connect # from the claude-config repo
bash ~/.claude/lib/seo-data/connect.sh --label <label> # from ANY directory (venv must exist)
```
This creates `~/.claude/.venv-seo-data/` (isolated venv, deps pinned in
`requirements.txt`), installs `google-auth`, `google-auth-oauthlib`,
`requests`, then runs `connect.py`: it opens a browser for OAuth consent and
asks for a **label** (e.g. `client-a`) to key the account — pick a name, not
an email, since the store never stores or requests the account's email.
`make seo-connect` creates `~/.claude/.venv-seo-data/` (isolated venv, deps
pinned in `requirements.txt`), installs `google-auth`,
`google-auth-oauthlib`, `requests`, then delegates to `connect.sh`. The
wrapper sources `~/.claude/.env` internally, prefers the venv python, and
runs `connect.py`: it opens a browser for OAuth consent and takes a
**label** (e.g. `client-a`) to key the account — pick a name, not an email,
since the store never stores or requests the account's email. Once the venv
exists, `connect.sh` alone connects further accounts from anywhere (the
`/seo connect [label]` skill verb uses exactly this path).
Before running it, set these 3 keys in `~/.claude/.env` (the canonical
vault; `link.sh` only symlinks the repo's `.env` to it and warns with a
@@ -77,6 +82,12 @@ fetch.sh queries --account client-a --property sc-domain:ex.com [--days 90] [--d
fetch.sh inspect --account client-a --property … --url https://ex.com/page
→ {"status":"ok","source":"gsc","indexed":true,"coverage":"…","last_crawl":"…"}
→ {"status":"degraded","reason":"…"}
fetch.sh forget --label client-a
→ {"status":"ok","removed":true|false} # false = label wasn't in the store
fetch.sh forget --all
→ {"status":"ok","cleared":<n>} # n = accounts removed
```
Rules that hold for every subcommand:
@@ -151,6 +162,12 @@ Security posture:
- **Also gitignored** (`.venv-seo-data/` and `seo-data/tokens.json` in
`.gitignore`) as a second, belt-and-suspenders guard in case a relative
path ever put either under the repo tree.
- **Removal is local-only.** `fetch.sh forget --label <x>` / `--all` (the
`/seo forget` skill verb) deletes the stored refresh token — it does NOT
revoke the OAuth grant at Google's end. For a real revocation, visit
https://myaccount.google.com/permissions with the account concerned and
remove the app's access; the deleted local token then becomes useless
everywhere, including to anyone who copied it beforehand.
## Graceful degradation
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# One-time OAuth consent wrapper — runnable from ANY directory:
# bash ~/.claude/lib/seo-data/connect.sh --label <label>
# Sources the env vault internally (never echoed), prefers the engine venv,
# then execs connect.py. Interactive by design: stdout carries the auth URL,
# stderr stays visible (unlike fetch.sh, there is no secret-leak surface to
# suppress — connect.py never prints tokens).
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${SEO_DATA_ENV_FILE:-${HOME}/.claude/.env}" # canonical; tests override to /dev/null
VENV_PY="${HOME}/.claude/.venv-seo-data/bin/python3"
# Whole-string label guard (shell-safe ASCII: leading alnum then alnum/._-).
# POSIX `case` in a C-locale subshell: no per-line grep pitfall (a newline is
# a non-allowed byte caught by *[!...]*), no locale range surprise, no second
# grammar to differ from. Empty and non-alnum-leading are rejected too.
_label_safe() ( LC_ALL=C; case "$1" in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) exit 1;; esac )
# Strict argv grammar (parser-differential defense): accept ONLY the exact
# forms `--label <value>` / `--store <path>` — never `=`-joined or abbreviated
# forms — so the downstream argparse can never resolve a token this guard
# didn't see. Runs BEFORE any secret is loaded.
argv=("$@"); n=${#argv[@]}; i=0
while [ "$i" -lt "$n" ]; do
case "${argv[$i]}" in
--label)
if ! _label_safe "${argv[$((i+1))]:-}"; then
echo "connect.sh: unsafe label — must match ^[A-Za-z0-9][A-Za-z0-9._-]*\$" >&2
exit 2
fi
i=$((i+2)) ;;
--store) i=$((i+2)) ;;
*)
echo "connect.sh: unsupported argument '${argv[$i]}' — usage: connect.sh --label <label> [--store <path>]" >&2
exit 2 ;;
esac
done
# Load secrets quietly (sourced, never echoed).
if [ -f "$ENV_FILE" ]; then
set -a; # shellcheck source=/dev/null
. "$ENV_FILE"; set +a
fi
PY="python3"; [ -x "$VENV_PY" ] && PY="$VENV_PY"
exec "$PY" "$HERE/connect.py" "$@"
+17 -1
View File
@@ -20,11 +20,27 @@ fi
# stdlib-only paths (accounts / mock / degrade).
PY="python3"; [ -x "$VENV_PY" ] && PY="$VENV_PY"
# Whole-string label guard (shell-safe ASCII). POSIX `case` in a C-locale
# subshell — newline-proof and locale-independent, unlike a per-line grep.
_label_safe() ( LC_ALL=C; case "$1" in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) exit 1;; esac )
cmd="${1:-}"; shift || true
case "$cmd" in
accounts) exec "$PY" "$HERE/tokenstore.py" list --file "$STORE" ;;
crux|queries|inspect)
exec "$PY" "$HERE/google_seo.py" "$cmd" --store "$STORE" "$@" ;;
*) echo '{"status":"error","reason":"usage: fetch.sh {accounts|crux|queries|inspect} [flags]"}'
forget)
# forget --label <label> → drop one account; forget --all → empty the store.
# Local removal only — does NOT revoke the grant at Google's end.
# Label charset guard: store keys stay shell-safe wherever an agent
# interpolates them into a command line (defense-in-depth vs injection).
if [ "${1:-}" = "--all" ]; then
exec "$PY" "$HERE/tokenstore.py" clear --file "$STORE"
elif [ "${1:-}" = "--label" ] && _label_safe "${2:-}"; then
exec "$PY" "$HERE/tokenstore.py" remove --file "$STORE" --label "$2"
fi
echo '{"status":"error","reason":"usage: fetch.sh forget {--label <label>|--all} (label charset: A-Za-z0-9._-)"}'
exit 2 ;;
*) echo '{"status":"error","reason":"usage: fetch.sh {accounts|crux|queries|inspect|forget} [flags]"}'
exit 2 ;;
esac
+73 -1
View File
@@ -106,12 +106,76 @@ has "connect.persist wrote prop" "$L3" 'sc-domain:x.com'
hasnt "connect.persist redacts" "$L3" 'RT_X'
rm -rf "$TMP3"
echo "── forget (remove/clear) ──"
TMP6="$(mktemp -d)"; S6="$TMP6/tokens.json"
python3 "$SD/tokenstore.py" set --file "$S6" --label keep --refresh-token RT_KEEP \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:k.com >/dev/null
python3 "$SD/tokenstore.py" set --file "$S6" --label drop --refresh-token RT_DROP \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:d.com >/dev/null
RM="$(python3 "$SD/tokenstore.py" remove --file "$S6" --label drop)"
has "remove reports ok" "$RM" '"status": "ok"'
has "remove reports removed" "$RM" '"removed": true'
hasnt "remove prints no token" "$RM" 'RT_DROP'
L6="$(python3 "$SD/tokenstore.py" list --file "$S6")"
has "remove keeps others" "$L6" '"keep"'
hasnt "removed label gone" "$L6" '"drop"'
RM2="$(python3 "$SD/tokenstore.py" remove --file "$S6" --label ghost)"
has "remove missing = false" "$RM2" '"removed": false'
CL="$(python3 "$SD/tokenstore.py" clear --file "$S6")"
has "clear reports ok" "$CL" '"status": "ok"'
has "clear reports count" "$CL" '"cleared": 1'
L7="$(python3 "$SD/tokenstore.py" list --file "$S6")"
has "clear empties store" "$L7" '"accounts": []'
PERM6="$(stat -c '%a' "$S6")"
[ "$PERM6" = "600" ] && ok "store stays 0600 after clear" || no "store 0600 after clear" "got $PERM6"
# via the real fetch.sh dispatch layer
python3 "$SD/tokenstore.py" set --file "$S6" --label back --refresh-token RT_BACK \
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:b.com >/dev/null
FG="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label back)"; FRC=$?
has "fetch forget removes" "$FG" '"removed": true'
[ "$FRC" = "0" ] && ok "fetch forget exit 0" || no "fetch forget exit 0" "got $FRC"
FB="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget)"; FRC2=$?
has "forget bad usage json" "$FB" '"status"'
[ "$FRC2" = "2" ] && ok "forget bad usage exit 2" || no "forget bad usage exit 2" "got $FRC2"
FA="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --all)"
has "fetch forget --all ok" "$FA" '"status": "ok"'
FI="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label 'x;touch /tmp/pwn')"; FIRC=$?
has "forget unsafe label json" "$FI" '"status":"error"'
[ "$FIRC" = "2" ] && ok "forget unsafe label exit 2" || no "forget unsafe label exit 2" "got $FIRC"
# embedded-newline label must NOT pass the per-line-grep pitfall
FN="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label "$(printf 'ok\nrm -rf x')")"; FNRC=$?
has "forget newline label json" "$FN" '"status":"error"'
[ "$FNRC" = "2" ] && ok "forget newline label exit 2" || no "forget newline label exit 2" "got $FNRC"
rm -rf "$TMP6"
echo "── connect.sh (offline negative) ──"
CN="$(SEO_DATA_ENV_FILE=$NOENV env -u GOOGLE_OAUTH_CLIENT_ID -u GOOGLE_OAUTH_CLIENT_SECRET \
bash "$SD/connect.sh" --label t 2>&1)"; CNRC=$?
[ "$CNRC" != "0" ] && ok "connect.sh no-creds nonzero" || no "connect.sh no-creds nonzero" "got 0"
has "connect.sh creds gate msg" "$CN" 'GOOGLE_OAUTH_CLIENT_ID'
CU="$(SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label 'x;y' 2>&1)"; CURC=$?
[ "$CURC" = "2" ] && ok "connect.sh unsafe label exit 2" || no "connect.sh unsafe label exit 2" "got $CURC"
has "connect.sh label guard msg" "$CU" 'unsafe label'
# parser-differential bypasses must be rejected too (=-joined, abbreviated)
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label='x;y' >/dev/null 2>&1; CJRC=$?
[ "$CJRC" = "2" ] && ok "connect.sh =-joined rejected" || no "connect.sh =-joined rejected" "got $CJRC"
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --labe 'x;y' >/dev/null 2>&1; CBRC=$?
[ "$CBRC" = "2" ] && ok "connect.sh abbrev rejected" || no "connect.sh abbrev rejected" "got $CBRC"
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label "$(printf 'ok\nrm -rf x')" >/dev/null 2>&1; CWRC=$?
[ "$CWRC" = "2" ] && ok "connect.sh newline rejected" || no "connect.sh newline rejected" "got $CWRC"
# a VALID label must still reach the creds gate (guard is not over-tight)
CV="$(SEO_DATA_ENV_FILE=$NOENV env -u GOOGLE_OAUTH_CLIENT_ID -u GOOGLE_OAUTH_CLIENT_SECRET \
bash "$SD/connect.sh" --label ok-1.2_3 2>&1)"; CVRC=$?
[ "$CVRC" = "1" ] && ok "connect.sh valid label reaches gate" || no "connect.sh valid label reaches gate" "got $CVRC"
has "connect.sh valid gate msg" "$CV" 'GOOGLE_OAUTH_CLIENT_ID'
echo "── wiring locks ──"
tf() { if grep -qF -- "$3" "$2" 2>/dev/null; then ok "$1"; else no "$1" "missing: $3"; fi; }
tf "env.example client id" "$REPO/.env.example" "GOOGLE_OAUTH_CLIENT_ID="
tf "env.example crux key" "$REPO/.env.example" "CRUX_API_KEY="
tf "makefile seo-connect" "$REPO/Makefile" "seo-connect:"
tf "seo-connect sources env" "$REPO/Makefile" ".claude/.env"
tf "makefile delegates wrapper" "$REPO/Makefile" "lib/seo-data/connect.sh"
tf "connect.sh sources vault" "$SD/connect.sh" ".claude/.env"
tf "makefile discovers test" "$REPO/Makefile" "lib/seo-data/*.test.sh"
tf "install prompts connect" "$REPO/install.sh" "make seo-connect"
tf "doctor checks seo-data" "$REPO/doctor.sh" "seo-data"
@@ -125,9 +189,17 @@ tf "analyzer calls fetch queries" "$REPO/agents/seo-analyzer.md" "fetch.sh queri
tf "analyzer gsc subsection" "$REPO/agents/seo-analyzer.md" "Performance GSC"
tf "catalog gsc oauth entry" "$REPO/agents/resources/automation-catalog.md" "make seo-connect"
echo "── account-mgmt locks ──"
tf "skill routes account verbs" "$REPO/skills/seo/SKILL.md" "forget --all"
tf "skill connect wrapper path" "$REPO/skills/seo/SKILL.md" "lib/seo-data/connect.sh"
tf "skill revocation notice" "$REPO/skills/seo/SKILL.md" "myaccount.google.com/permissions"
tf "skill label charset rule" "$REPO/skills/seo/SKILL.md" "A-Za-z0-9._-"
echo "── readme lock ──"
tf "readme documents fetch.sh" "$REPO/lib/seo-data/README.md" "fetch.sh"
tf "readme documents seo-connect" "$REPO/lib/seo-data/README.md" "make seo-connect"
tf "readme documents forget" "$REPO/lib/seo-data/README.md" "forget --all"
tf "readme revocation note" "$REPO/lib/seo-data/README.md" "myaccount.google.com/permissions"
echo ""
echo "seo-data engine: $PASS pass, $FAIL fail"
+57 -14
View File
@@ -2,6 +2,7 @@
"""Label-keyed OAuth refresh-token store. Atomic writes under an fcntl lock.
No third-party deps — must run without the venv (used by the offline test path)."""
import argparse, fcntl, json, os, tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
def load(path):
@@ -21,23 +22,18 @@ def list_accounts(path):
def get_refresh_token(path, label):
return load(path).get("accounts", {}).get(label, {}).get("refresh_token")
def save_account(path, label, refresh_token, scopes, properties):
dirpath = os.path.dirname(path) or "."
os.makedirs(dirpath, mode=0o700, exist_ok=True)
os.chmod(dirpath, 0o700) # re-assert invariant (makedirs no-ops if dir exists)
@contextmanager
def _locked(path):
"""Exclusive fcntl lock around a store mutation (serializes writers)."""
lock_path = path + ".lock"
with open(lock_path, "w") as lock:
os.chmod(lock_path, 0o600) # defense-in-depth (empty flock handle, never holds token)
fcntl.flock(lock, fcntl.LOCK_EX) # serialize concurrent connects
data = load(path)
data.setdefault("version", 1)
data.setdefault("accounts", {})
data["accounts"][label] = {
"refresh_token": refresh_token,
"scopes": scopes,
"granted_at": datetime.now(timezone.utc).isoformat(),
"properties": properties,
}
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def _atomic_write(path, data):
"""tmp → fsync → chmod 0600 → atomic rename, in the store's directory."""
dirpath = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
@@ -49,6 +45,43 @@ def save_account(path, label, refresh_token, scopes, properties):
if os.path.exists(tmp):
os.unlink(tmp)
def save_account(path, label, refresh_token, scopes, properties):
dirpath = os.path.dirname(path) or "."
os.makedirs(dirpath, mode=0o700, exist_ok=True)
os.chmod(dirpath, 0o700) # re-assert invariant (makedirs no-ops if dir exists)
with _locked(path):
data = load(path)
data.setdefault("version", 1)
data.setdefault("accounts", {})
data["accounts"][label] = {
"refresh_token": refresh_token,
"scopes": scopes,
"granted_at": datetime.now(timezone.utc).isoformat(),
"properties": properties,
}
_atomic_write(path, data)
def remove_account(path, label):
"""Drop one label from the store. Returns True if it existed."""
if not os.path.exists(path):
return False
with _locked(path):
data = load(path)
existed = data.get("accounts", {}).pop(label, None) is not None
if existed:
_atomic_write(path, data)
return existed
def clear_accounts(path):
"""Empty the store (file and perms kept). Returns removed count."""
if not os.path.exists(path):
return 0
with _locked(path):
data = load(path)
count = len(data.get("accounts", {}))
_atomic_write(path, {"version": 1, "accounts": {}})
return count
def _cli():
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="cmd", required=True)
@@ -58,10 +91,20 @@ def _cli():
ps.add_argument(flag, required=True)
ps.add_argument("--scopes", default="")
ps.add_argument("--properties", default="")
pr = sub.add_parser("remove")
for flag in ("--file", "--label"):
pr.add_argument(flag, required=True)
pc = sub.add_parser("clear"); pc.add_argument("--file", required=True)
try:
args = p.parse_args()
if args.cmd == "list":
print(json.dumps({"status": "ok", "accounts": list_accounts(args.file)}))
elif args.cmd == "remove":
print(json.dumps({"status": "ok",
"removed": remove_account(args.file, args.label)}))
elif args.cmd == "clear":
print(json.dumps({"status": "ok",
"cleared": clear_accounts(args.file)}))
else:
save_account(args.file, args.label, getattr(args, "refresh_token"),
[s for s in args.scopes.split(",") if s],
+41 -2
View File
@@ -36,6 +36,43 @@ entry point for any SEO/GEO work on a web project.
Read `resources/depth-matrix.md` at the start of STEP 0 — it pre-answers
several questions and keeps token cost down by removing repeated explanations.
## STEP -1 — Account management verbs (intercept BEFORE any audit)
If `$ARGUMENTS` starts with `connect`, `accounts`, or `forget`, run the
matching action below and STOP — no audit, no analyzer dispatch, no report.
Tilde paths mandatory (this skill runs from the audited project's directory,
not the claude-config repo). Anything else falls through to STEP 0 unchanged.
**Label safety rule (both verbs):** a label MUST match
`^[A-Za-z0-9][A-Za-z0-9._-]*$` — anything else (spaces, quotes, `;`, `$`,
backticks…), refuse it and ask for another name; the engine also rejects it
(exit 2). ALWAYS single-quote the label when composing the Bash call
(`--label 'client-a'`) — never paste it unquoted into a command line.
- **`connect [label]`** — connect a Google account (one-time OAuth consent):
1. No label given → ask for one (a client/site name, not an email).
2. Run in background: `bash ~/.claude/lib/seo-data/connect.sh --label <label>`
— the wrapper sources `~/.claude/.env` itself and works from any
directory (from the claude-config repo, `make seo-connect` also works
and builds the venv first; use it if the venv doesn't exist yet).
3. Read the background output for the authorization URL it prints and hand
that URL to the user — they consent in their browser; the localhost
callback completes the flow on its own.
4. On success, report the label + discovered Search Console properties.
On failure, surface the error verbatim (e.g. missing
`GOOGLE_OAUTH_CLIENT_ID/SECRET` in `~/.claude/.env`, 403 API disabled).
- **`accounts`** — list connected accounts:
`bash ~/.claude/lib/seo-data/fetch.sh accounts` → render one line per
label with its properties; `"accounts": []` → say none connected and
point at `/seo connect`.
- **`forget <label>`** / **`forget --all`** — remove one account / empty the
store: `bash ~/.claude/lib/seo-data/fetch.sh forget --label <label>` (or
`forget --all`). Confirm with the user BEFORE `--all`. ALWAYS append this
notice to the result: local removal deletes the stored refresh token but
does NOT revoke the grant at Google — for a real revocation, visit
https://myaccount.google.com/permissions (account concerned) and remove
the app's access there.
## STEP 0 — Collect shared context (ONCE)
Before spawning any agent, collect the context both agents need.
@@ -82,8 +119,10 @@ COMPTE GOOGLE pour cet audit FULL :
1. <label> — <property 1>, <property 2>, ...
2. <label> — <property>
...
[connecter un nouveau compte] — lancer `make seo-connect` (depuis le
repo claude-config, une fois par compte), puis relancer /seo
[connecter un nouveau compte] — `/seo connect <label>` (ou
`bash ~/.claude/lib/seo-data/connect.sh --label <label>` depuis
n'importe quel projet ; `make seo-connect` depuis le repo claude-config
construit aussi la venv), puis relancer /seo
[Ignorer] — continuer sans GSC/CrUX (PageSpeed anonyme uniquement,
dégradation normale — cf. SEO.md §11)