Merge feature/seo-account-mgmt into develop

This commit is contained in:
Bastien Chanot
2026-07-10 12:52:54 +02:00
11 changed files with 319 additions and 28 deletions
+2
View File
@@ -375,3 +375,5 @@ rules:
- GSC+CrUX data layer for `/seo` FULL shipped end-to-end (subagent-driven, superpowers): design→plan→8 tasks→final review→merge `bb1fbb2` on develop. Engine `lib/seo-data/` (label-keyed OAuth token store 0600/0700, CrUX field + GSC Search-Analytics/URL-Inspection, fail-open `fetch.sh`, `make seo-connect` consent), wired into `/seo` FULL (STEP 0 account select, CrUX-primary CWV, "Performance GSC" quick-wins). 49/49 engine tests + full `make test` green throughout. Final opus whole-branch review: security PASS, 0 Critical/Important, 5 Minors all deferred to a later chore sweep.
- Decided [[BDR-063]] OAuth installed-app + explicit `(account,property)` args (no global state) → multi-account no-conflict. Learned [[LRN-119]] fail-open engine contract (always-JSON, lazy imports, degrade-not-crash), [[LRN-120]] final-review base = merge-base not ledger BASE (caught a misleading 881-vs-2163-ins diff).
- Docs synced (`/doc`, `4a15c73` on `chore/doc-sync-gsc-crux`): README (seo-connect, make-test glob, /seo row) + USAGE (/seo FULL real-data) + CHANGELOG Added entry. Pending: merge `chore/doc-sync-gsc-crux`→develop (human GO), then delete transient spec+plan `docs/superpowers/…gsc-crux…`.
- Post-ship housekeeping merged to develop: `chore/doc-sync-gsc-crux` (`8a1fac0`, docs+memory+transient-cleanup), then `bugfix/seo-connect-env-source` (`61a98d3`) — `make seo-connect` never sourced `~/.claude/.env` so OAuth creds never reached connect.py; found by real `make seo-connect` run (403 discover_properties after consent = Search Console API not enabled + the env bug). Live OAuth validated end-to-end by user (consent OK, app published to Production for non-expiring refresh token).
- `/feat` feature/seo-account-mgmt (unmerged, human GO pending): account-management verbs — tokenstore remove/clear, fetch.sh forget, connect.sh wrapper (sources env, runs from any project), `/seo connect|accounts|forget` routing, Makefile delegates to wrapper. Commits `8bf7459` (feat) + `887341d` (doc USAGE). Security loop hit its cap: 3 GATE-2 BLOCKs on the label guard (injection → parser differential → per-line-grep newline), closed categorically by a whole-string POSIX `case` guard [[LRN-121]]; final fresh scan PASS (~50 vectors, 0 bypass). 85/85 engine + `make test` green throughout. forget = local delete, NOT Google revocation (surfaces myaccount.google.com/permissions).
+6
View File
@@ -1208,3 +1208,9 @@ rules:
- **why it matters**: the final review is the last gate before merge; a wrong base hides real changes or invents fake ones. The ledger BASE is a task resume-map, not a merge-delta anchor.
- **future application**: for ANY whole-branch/final review, derive base from `git merge-base <target> HEAD`, never a stored/remembered SHA. Sanity-check: does `git log BASE..HEAD` list ONLY this branch's commits, nothing foreign? Diff-stats differ between candidate bases → recorded one is stale, trust merge-base.
- **cousin**: [[LRN-119]] (same GSC+CrUX build); SDD skill's own "never HEAD~1" warning (same base-selection bug class).
## LRN-121 — Shell allowlist validation: `grep -Eq` is fragile; use a whole-string POSIX `case`
- **pattern**: guarding a user-supplied label to shell-safe ASCII with `printf '%s' "$v" | grep -Eq '^[A-Za-z0-9._-]+$'` failed 3 adversarial gate passes in a row: (1) command-injection framing (label interpolated into an agent-composed Bash line); (2) parser differential — the guard pre-scanned argv for the literal token `--label` while the downstream `argparse` ALSO accepts `--label=v` and abbreviations (`--labe`, `allow_abbrev=True`), so those forms reached the parser unchecked; (3) `grep -q` matches PER LINE, so a label with an embedded newline (`ok\nrm -rf`) passes because its FIRST line matches. Fix = replace the whole mechanism, don't patch again: `_label_safe() ( LC_ALL=C; case "$1" in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) exit 1;; esac )` — POSIX `case`, whole-string, C-locale subshell. No grep (no per-line), no regex, no second grammar to differ from; a newline is just a non-allowed byte caught by `*[!...]*`; `LC_ALL=C` stops UTF-8 collation widening `[A-Za-z0-9]` to homoglyphs (U+FF11, Kelvin U+212A).
- **why it matters**: three distinct bypasses of the SAME guard = the approach was wrong, not each patch. `grep`'s line-orientation + locale-sensitive ranges, plus argv-prescan-vs-real-parser grammar drift, are the three classic ways an allowlist "passes" a string it shouldn't. Whole-string `case` in C locale closes all three at once. These were defense-in-depth (downstream used `"$2"`/`"$@"`/JSON-key, never `sh -c`/`eval` → not exploitable in the real exec chain) — but the backstop still took a categorical rewrite, and 3 security-gate BLOCKs to get there.
- **future application**: validate shell input WHOLE-STRING (`case` or bash `[[ =~ ]]`), never `grep -q` (per-line). Set `LC_ALL=C` for byte-wise ranges. A guard that pre-scans argv must be STRICTER than the downstream parser (reject `=`-joined/abbrev) or validate post-parse against the value the parser settled on. When a fix is bypassed twice → STOP patching, replace the mechanism (re-plan, not whack-a-mole).
- **cousin**: [[LRN-119]] (fail-open engine this hardens), [[BDR-063]] (token store whose labels these guard), [[LRN-045]] (renaming-command leak-guard regexes — same charset-guard family).
@@ -0,0 +1,52 @@
# CONTRACT — seo-account-mgmt
- date: 2026-07-10 | flow: feat | branch: feature/seo-account-mgmt
- status: active
## REQUEST (verbatim — IMMUTABLE)
"J'aimerais qu'on rajoute quand meme une option au skill pour juste connecter
le compte. du style un argument au skill seo pour fiare un truc du genre /set
seo-connect ou quelque chjose comme cas. Et aussi pouvoir clean la liste des
compte deja enregister. pouvoir supprimer des compte ou tout supprimer"
— design proposal validated by user ("go pour l'un puis l'autre oui"):
`/seo connect [label]` / `/seo accounts` / `/seo forget <label>` /
`/seo forget --all`; tokenstore remove+clear verbs; fetch.sh forget dispatch;
new connect.sh wrapper (sources env internally, usable from any project);
Makefile delegates to it; SKILL.md arg routing + STEP 0 fix; forget output
must state local removal ≠ Google revocation (myaccount.google.com/permissions).
## CLARIFICATIONS
none — request complete (design pre-validated in conversation).
## ACCEPTANCE CRITERIA
1. `python3 lib/seo-data/tokenstore.py remove --file F --label X` deletes only
label X (others preserved), prints `{"status":"ok","removed":true|false}`,
never prints a refresh token; atomic write + fcntl lock as set.
2. `python3 lib/seo-data/tokenstore.py clear --file F` empties the store
(subsequent list → `"accounts": []`), JSON ok, same write discipline.
3. Fail-open preserved on new verbs: bad usage → `{"status":"error",...}` +
exit 2; unexpected error → degraded JSON (existing _cli try/except covers).
4. `fetch.sh forget --label X` / `forget --all` dispatch to remove/clear
within the existing contract (JSON stdout, exit 0 ok, exit 2 bad usage);
`fetch.sh forget` with no/invalid flag → exit 2 + JSON.
5. New `lib/seo-data/connect.sh`: sources `${SEO_DATA_ENV_FILE:-~/.claude/.env}`
internally (set -a, never echoed), picks venv python else system, execs
connect.py with passed args; with no creds exits nonzero with the
"Set GOOGLE_OAUTH_CLIENT_ID/SECRET" gate message (deterministic, offline).
6. Makefile `seo-connect` delegates to connect.sh (env-sourcing duplication
from caa5bed removed); venv creation + pip install kept before.
7. `skills/seo/SKILL.md` routes `connect [label]` / `accounts` /
`forget <label>|--all` BEFORE the audit flow (audit `/seo <url>` unchanged);
forget path includes the Google revocation notice
(myaccount.google.com/permissions); STEP 0 no longer proposes bare
`make seo-connect` as the only path (connect.sh tilde path offered).
8. `lib/seo-data/README.md` documents connect.sh, forget verbs, revocation note.
9. `lib/seo-data/seo-data.test.sh` covers: remove keeps others / removed:false
on missing label / clear empties / redaction on remove / forget via fetch.sh
(JSON + exit codes, bad usage 2) / connect.sh offline negative path; plus
wiring locks (connect.sh sources vault, Makefile delegates, SKILL routes,
README documents). Whole suite + `make test` green.
10. No commit attribution trailers; tilde paths for engine calls in SKILL.md.
## FILE SCOPE
lib/seo-data/tokenstore.py, lib/seo-data/fetch.sh, lib/seo-data/connect.sh (new),
lib/seo-data/seo-data.test.sh, lib/seo-data/README.md, Makefile, skills/seo/SKILL.md
+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 \
+1 -1
View File
@@ -142,7 +142,7 @@ Tu veux...
| `/refactor` | Améliorer un fichier sans changer le comportement | Rapport de violations d'abord, modif ensuite |
| `/code-clean` | Dead code, violations de style | Audit + rapport, fixes après approbation |
| `/doc` | Docs périmées après des changements | Audit drift code↔docs, patch chirurgical |
| `/seo` | Audit SEO/GEO complet | Détecte framework, audite meta/OG/sitemap ; en FULL, choix du compte Google puis données réelles Search Console + CrUX (terrain) si connecté via `make seo-connect`, sinon repli PageSpeed anonyme |
| `/seo` | Audit SEO/GEO complet | Détecte framework, audite meta/OG/sitemap ; en FULL, choix du compte Google puis données réelles Search Console + CrUX (terrain) si connecté via `make seo-connect`, sinon repli PageSpeed anonyme. Gestion des comptes sans audit : `/seo connect [label]`, `/seo accounts`, `/seo forget <label>\|--all` |
| `/geo` | Audit GEO uniquement (IA) | Visibilité ChatGPT, Perplexity, Claude, Gemini… |
| `/commit-change` | Commits bien structurés | Groupe les changements par unité logique |
| `/gitflow` | Opérations de branches gitflow | Bootstrap main+develop, branche typée, merge dirigé |
+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,14 +22,34 @@ def list_accounts(path):
def get_refresh_token(path, label):
return load(path).get("accounts", {}).get(label, {}).get("refresh_token")
@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)
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:
json.dump(data, f, indent=2)
f.flush(); os.fsync(f.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, path) # atomic
finally:
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)
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
with _locked(path):
data = load(path)
data.setdefault("version", 1)
data.setdefault("accounts", {})
@@ -38,16 +59,28 @@ def save_account(path, label, refresh_token, scopes, properties):
"granted_at": datetime.now(timezone.utc).isoformat(),
"properties": properties,
}
fd, tmp = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.flush(); os.fsync(f.fileno())
os.chmod(tmp, 0o600)
os.replace(tmp, path) # atomic
finally:
if os.path.exists(tmp):
os.unlink(tmp)
_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()
@@ -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)