forked from bchanot/claude
Merge feature/seo-account-mgmt into develop
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user