Commit Graph
189 Commits
Author SHA1 Message Date
Bastien Chanot 0e1b89c71a chore(hooks): remove config-protection edit-block guardrail
Full removal per user request: the PreToolUse hook that blocked model
Edit/Write on quality-gate files (settings.json, gitflow.sh, .githooks,
doctor.sh, hooks, lib/tests, lint configs) plus its one-shot sentinel.

- delete hooks/config-protection.sh
- delete lib/tests/config-protection.test.sh
- deregister the hook from settings.json (rtk-rewrite PreToolUse kept)
- drop the README mention

Residual protection unchanged: gitflow pre-commit guard + Gitea branch
protection still block direct code commits to main/develop.
2026-07-17 21:56:32 +02:00
Bastien Chanot b00e8ef442 feat(seo-data): safe_fetch — resolve-then-pin, close DNS-rebinding + SSRF
By-principle hardening. H1's url-guard validates the NAME; urlopen then resolved
AND connected — two DNS lookups with a window a hostile authority uses to answer
PUBLIC to validation and PRIVATE (169.254.169.254 metadata, 127.0.0.1, the LAN)
to the connect. A name-level guard cannot see that rebind.

safe_fetch collapses the two lookups into one: resolve ONCE, validate every IP
(ipaddress, dual-stack v4+v6), refuse if ANY is non-public (the multi-A vector),
connect to the exact validated IP with Host+SNI+cert for the real host — no
second resolution to poison. Redirects re-validate each hop (urlopen followed
them blind). One seam: sitemap._fetch, which linkgraph/render_check/drift all
call, so every network verb inherits it.

The load-bearing property (confirmed by the security review): classification is
on the OS-resolved address (sockaddr[0]), never the URL text — so octal/hex/
decimal literals, IPv4-mapped IPv6, NAT64, 6to4 are all defeated structurally,
not by enumeration.

Better than the source idea (claude-seo url_safety.py, MIT): dual-stack (theirs
IPv4-only), no global monkeypatch so thread-safe by construction (theirs locks a
patched getaddrinfo), stdlib-only (no requests). Proven end-to-end before
writing: pinned connect keeps SNI+cert for the real host.

NOT covered, stated not silent: shell `curl` in the agent specs (separate
process, unpinnable here). Smaller surface; `curl --resolve` is a separate change.

REVIEW-SURFACED (fresh security-auditor, adversarial, VERDICT PASS) — two real
holes it found while attacking the diff, both fixed here:
- billion-laughs REOPENED in C1b: _refuse_dtd scanned only raw[:4096], so a
  >4KB leading comment pushed <!DOCTYPE past the window while ET parsed AND
  EXPANDED the entities. Proven (&lol2; → "lollollollollol"), now a full-doc
  case-insensitive scan. This is a genuine fix to already-merged C1b, not this
  feature — fixed here rather than filed, per root-cause discipline.
- 192.88.99.0/24 (6to4-relay anycast) passed is_global as public — added to an
  extra special-use deny list.

Verified: rebind-to-metadata refused BEFORE any connect (injected resolver),
multi-A public+private refused, classifier fuzzed dual-stack incl. CGNAT/6to4,
non-http scheme refused, both review fixes proven with no false positive; real
fetch still works (zenquality 86 loc, lavageangels 24) through the pinned path;
all 4 verbs work end-to-end via fetch.sh; seo-data 210 → 221 pass, 0 fail; full
suite green; shellcheck + py_compile clean.
2026-07-17 19:58:59 +02:00
Bastien Chanot b271e83fb6 feat(seo-data): content_quality verb — deterministic filler/AI-slop signal
Cherry-picked from claude-seo (github.com/AgriciDaniel/claude-seo, MIT)
content_quality.py, rewritten to the lib/seo-data contract per BDR-070. The
Content Shape axis was 100% LLM judgement; this gives it a measured input.

fetch.sh content_quality (stdin or --file) → {filler_score, ai_pattern_score,
information_density, overall_quality, flags[], matches{}}. 100% deterministic:
QRG §4.6 filler list (26 phrases) + AI-pattern list (46) kept intact, regex
matching, no LLM. Stdlib only (argparse/json/re/sys/collections/typing).

Advisory, NOT a verdict — the point of the wiring. It never claims a page "is
AI-written" (LRN-131/133); flags are candidates for human review. geo-analyzer
STEP 8 Check 10 makes it a deterministic input that INFORMS checks 1-9, never
replaces them, never scored on its own. A low number is not an automatic
finding.

Detection proven both directions (a detector that always- or never-flags is
useless): filler+slop text → flags [filler, low-density], overall 34-49; clean
dense factual text (dates/EUR/percentages) → no flags, overall 90. Empty input →
degraded/empty_input, never zeros-as-a-result.

Verified: GATE 1 verifier CONFORME 10/10 (both directions exercised live, lists
diffed intact vs source, advisory language confirmed); GATE 2 self-scan clean
(only sink is read-only open() for --file); seo-data 190 → 210 pass, 0 fail;
full suite green; shellcheck + py_compile clean.
2026-07-17 19:06:58 +02:00
Bastien Chanot cfdd89e73b feat(seo-data): schema_gen verb — generate JSON-LD, not just audit it
Cherry-picked from claude-seo (github.com/AgriciDaniel/claude-seo, MIT)
schema_generate.py, rewritten to the lib/seo-data contract per BDR-070 — adapt,
never copy. The system audited JSON-LD but could not generate it; geo-analyzer's
G2 batch hand-wrote markup. Now it calls the verb.

fetch.sh schema_gen {reservation|order|discussion|profile} → fail-open envelope
{"status":"ok","source":"schema_gen","type":…,"jsonld":{…}}. Types: Reservation
(7 subtypes), OrderAction, DiscussionForumPosting, ProfilePage (sameAs/knowsAbout
for the entity graph). Stdlib only (import argparse, json — zero third-party),
_strip_nones so a null is never emitted, --script-tag wraps for direct paste.

Fail-open mirrors score.py's _cli exactly (the contract's named pattern): a
flag-omitted required field → argparse exit 2 + {"status":"error","bad_usage"};
a flag-present-but-empty field → {"status":"degraded","reason":"missing required
field: …"} exit 0. Never a traceback, never empty stdout (LRN-133: the
can't-generate case stays legible).

geo-analyzer G2 wiring preserves the data-integrity rule — the verb generates
STRUCTURE, unknown values stay [À COMPLÉTER], never invented.

Verified: GATE 1 verifier CONFORME 10/10 (exercised the fail-open edge cases +
diffed field surface against the source); GATE 2 self-scan clean (no
network/shell/secret/eval sink); seo-data 167 → 190 pass, 0 fail; full suite
green; shellcheck + py_compile clean.
2026-07-17 14:30:31 +02:00
Bastien Chanot 4818c6116f feat(seo-data): I7 — compute the score instead of feeling it
/harden has a real scale (SKILL.md:435 — Critique -15, Haute -8, Moyenne -3,
Basse -1, clamp [0,100]). /seo had none: every axis was felt, so two runs over
identical code could disagree. That is a credibility problem on its own, and
/client-handover gates on 17/20 — a wobbling number makes the gate arbitrary.
H2 sharpened it: now that drift reports what actually changed, a score moving
on its own is visibly noise.

The split is the whole point. WHICH findings exist and how severe each is
stays the LLM's judgement — irreducible, and I am not pretending otherwise.
The arithmetic stops being judgement: same findings in, same score out. Same
principle as grouping cannibalisation rows in the engine rather than handing a
model 1000 rows to add up.

Reuses /harden's scale, /5 into /20, so the family speaks one vocabulary
instead of two.

Two things it makes real that were prose:
- **N/A is not a zero.** R2 (client-rendered on-page) and I1 (unauditable
  off-page) both mandate excluding an axis and renormalising the rest. Both
  left that arithmetic to the model. Now the engine does it and refuses to let
  N/A behave like a zero — verified: all-20 axes with two N/A still yields
  global 20.0, not a dragged-down mean.
- **Prevalence.** affected/sampled shift severity ONE step (>=50% escalates, a
  single page de-escalates). A defect on 1 of 12 pages is not the defect on
  12 of 12, and flattening the two is part of what made the old numbers move.

Malformed input is an error, never a silently wrong number — unlike the fetch
verbs, a degrade here would mean bad input, not a network fact. Unknown
severity and unknown profile both rejected, tested.

Verified: hand-checkable arithmetic (haute+moyenne = 100-11 = 89 → 17.8;
critique+haute = 77 → 15.4), identical global across repeated runs, weights
renormalised to sum 1.0 with two axes N/A. seo-data 155 -> 167 pass, 0 fail;
full suite green; shellcheck + py_compile clean.
2026-07-17 13:29:14 +02:00
Bastien Chanot f69cfc5cb4 feat(seo-data): H2 — drift baseline; regressions vs changes, not prose
seo-analyzer.md:1365 keeps history as "date + score + key changes" — prose the
LLM writes about its own previous prose. Lossy, unreproducible, and
machine-uncomparable, so "the redesign silently dropped 40 canonicals" is
invisible unless someone happens to notice.

drift snapshots title/description/canonical/robots/h1_count/jsonld_types per
URL and diffs them. Stdlib only, no auth.

The classification IS the feature: LOSING a signal is a regression, CHANGING
one is a change that may well be intended. The engine says which kind; the
agent judges. A reworded title is not an alert; an evaporated canonical is.

Runs over the WHOLE sitemap, never a sample — caught while designing: a drift
computed over a sample that changes between runs compares nothing.

NOT rank tracking. That is the common misread of this same feature elsewhere;
positions come from GSC `queries`. This is on-page regression detection.

Also caught in my own draft before testing: _capture reused
sm._mock("page.html"), the exact single-fixture flaw I had already fixed in
linkgraph — one fixture cannot express a multi-page snapshot, every URL would
read identical. Now pages.json, same convention.

Proved on a planted failure rather than a happy path — two clean sites would
look identical to a detector that always returns []:
  v1 -> v2: canonical lost on /a, h1 + jsonld lost on /, title reworded,
  /gone removed, /neuve added
  → 3 regressions, 1 change, gone/new both detected, title correctly NOT a
    regression.

Store is ~/.claude/seo-data/drift/<host>.json, 0700, written via os.replace so
a crash never leaves a half-written baseline; a corrupt store degrades to
"first run" instead of killing the audit.

Verified: seo-data 144 -> 155 pass, 0 fail; full suite green.
2026-07-17 13:25:45 +02:00
Bastien Chanot 20d3082542 feat(seo-data,seo,geo): R2 — refuse to score what JS paints; no Playwright
Arbitrated (user): honest refusal on SPA, no headless browser.

STEP 2 has recorded `RENDERING: SSR/SSG/SPA/hybrid` since forever and NOTHING
ever acted on it. (The inventory claimed a "SPA severely limited" §0 flag
compensated — it does not exist. Seventh subagent claim this branch has had to
disprove.) So on a client-rendered site the FULL audit curls an empty shell,
every meta/H1/JSON-LD check reports "missing", and the agent emits a page of
false findings — plus a bundle that would "fix" tags which already exist.

rendercheck reads the verdict from what the server SENT. package.json cannot
tell a React SPA from a Next.js SSR app; the served bytes can. Stdlib only.

The refusal is the point:
- client-rendered → On-page is N/A, excluded from the weighted global, NOT
  scored zero. A zero says "your on-page is bad"; N/A says "we could not see
  it". Only one is true, and /client-handover gates on this number.
- No bundle item may come from a live on-page check on such a site.
- The report still says what IS auditable (robots, sitemap, headers, config,
  CrUX field data — real users, hydration included — GSC, legal, images)
  rather than returning an empty verdict.
- geo refuses Content Shape the same way, and states the sharper fact: AI
  crawlers are WORSE at JS than Googlebot. GPTBot/PerplexityBot/ClaudeBot
  fetch HTML and largely do not execute it, so a client-rendered site is not
  merely unauditable by us — it is near-invisible to the engines this audit
  exists to serve. §0 alert + SSR/SSG as the top user action.

Script/style text is not page text: a React shell with a fat inline
window.__INITIAL_STATE__ measures 7 chars. Without that skip a 200 KB bundle
reads as a rich page — the detector would fail exactly where it matters.

Verified on both extremes, not just the happy path: zenquality 7650 chars/1
h1/9 jsonld and lavageangels356 13973/1/1 → server-rendered, no warning; a
Vite/React shell fixture → client-rendered, 7/0/0, warned.

seo-data 136 -> 144 pass, 0 fail; full suite green; shellcheck + py_compile
clean.
2026-07-17 12:44:43 +02:00
Bastien Chanot fe41986be9 feat(seo-data): C3 — internal link graph; orphans + click depth, measured
seo-analyzer.md:613 asks "Every important page reachable within 3 clicks?"
and :616 asks "Orphan pages (no inbound internal links)?". Neither ever had a
command — same shape as the sameAs check before W3. This is that command.

My earlier reservation ("costs a lot of network") was wrong and the
measurement killed it: 24 pages in 2.7s, 86 in 3.8s. Cheap enough to always
run on FULL.

EXHAUSTIVE OR NOTHING is the design constraint, not a nicety. Orphans cannot
be sampled: proving a page has no inbound link means having read every other
page. So when the crawl is capped or any page fails, orphans are WITHHELD —
`orphans_withheld: true` and no list. A false orphan ("page X has no inbound
links" when it does) sends a client fixing what is not broken; that is the
worst finding this tool could emit. The cap does not degrade the result, it
invalidates it.

SPA refusal: on a client-rendered site the links are not in the HTML and
every page reads as orphaned. That is catastrophic, so an empty graph returns
degraded/no_links_in_html instead of a full false-positive list. No JS
rendering by design — that is the R1/R2 arbitration, not something to smuggle
in here.

Verified against BOTH live sites and against a planted failure, because two
clean results are not evidence a detector detects:
- native PHP: 24 pages, 335 links, depth 2, 0 orphans
- Astro: 86 pages, 2015 links, depth 2, 0 orphans
- fixture with a planted orphan + a 4-click chain: both found. Filters proven
  on real shapes seen live — /css/main.css?v=1778157313, #anchors, mailto:,
  tel:, external hosts, .png. /b/ in markup vs /b in sitemap unify to one node
  rather than a phantom orphan pair.

Fixed a flaw in my own mock while writing that test: a single page.html
fixture cannot express a GRAPH (every node gets identical links), so the mock
is now pages.json = {url: html}.

Verified: seo-data 122 -> 136 pass, 0 fail; full suite green; shellcheck +
py_compile clean.
2026-07-17 12:32:42 +02:00
Bastien Chanot dca977bb27 fix(seo-data,seo): backtest on a second, native site — two real bugs
Everything on this branch was grounded on ONE Astro repo. A native PHP site
(lavageangels356.fr) broke two things that looked fine there.

BUG 1 — sitemap counted images as pages. _locs matched
`el.tag.endswith("}loc")`, and <image:loc> from Google's image-sitemap
namespace ALSO ends with '}loc'. Astro's sitemap has no image extension, so
this was invisible. The native site's does: 24 <url> + 3 <image:loc> came back
as count=27. The COVERAGE denominator was 12.5% too high and img/logo.png was
about to be sampled and audited as a page.
Fixed with two locks: walk the DIRECT children of each <url>/<sitemap> instead
of root.iter() (which alone excludes <image:image><image:loc>), and test the
sitemaps.org namespace explicitly. Regression fixture carries the image
extension; the old endswith code returns 9 URLs against it, the new one 7 with
zero images.
Verified both sites: native 27 -> 24, zero images; Astro unchanged at 86.

BUG 2 — the C1c family heuristic was tuned to one URL layout. "First path
segment" works for NESTED city pages (/creation-site-internet/essonne-91/ →
25 pages, 1 family) and FAILS for FLAT ones (/lavage-auto-pomponne,
/lavage-auto-torcy → 8 pages, 8 singletons). Consequence: C1c's rule "sample
>=3 from the largest family" would have targeted /services (5) and missed the
8 city pages entirely — the exact doorway-page risk the 30/70 rule exists to
catch.
Family is now "shared parent path OR shared slug prefix (>=3 URLs sharing 2+
hyphen tokens)", with both real layouts as the worked examples, plus a
sanity-check: a sitemap yielding almost as many families as URLs has defeated
the heuristic, not proved the site has no templates. Fixed in seo-analyzer and
in the geo pointer that referenced it.

Backtest results on the native site for everything else: url-guard accepts the
domain; source-scope excludes only .git (no dist/build/out exists — the
exclusions are correctly no-ops, and cache/ holds only .htaccess+.gitignore so
it is rightly untouched); the sameAs check runs and finds zero (a real GEO gap
for that site, not a tool bug); links are present in the served HTML (PHP is
SSR), so C3 is feasible there.

Verified: seo-data 119 -> 122 pass, 0 fail; full suite green; py_compile clean.
2026-07-17 12:28:36 +02:00
Bastien Chanot 3a15643c2c feat(seo-data): C2 — cannibalisation from Google's own data, one param away
The inventory called this "no duplicate-content / cannibalisation detection".
Splitting that into its two halves shows one is free and the other is a trap.

CANNIBALISATION — free, and the data was already reachable. Search Analytics
has always accepted several dimensions at once ("no limit to the number of
dimensions that you can group by"); this engine only ever sent
`"dimensions": [dim]` and _norm_queries only ever read `keys[0]`. So
query+page — the pairing that exposes the conflict — was one parameter away
and nobody asked. Same shape of win as W1.

  fetch.sh cannibal → queries where 2+ of OUR pages compete, ranked by total
  impressions, strongest page first inside each. Same auth, same quota family,
  no new scope. `capped` reports a full row window rather than presenting a
  truncated list as exhaustive — same rule as COVERAGE and the sitemap caps.

Grouping happens in the engine, deterministically: asking an LLM to group
1000 rows by query is arithmetic it should never be handed.

Backward compatible: rows gained `keys` (the list the API actually returns);
`key` stays as keys[0], so the single-dim quick-wins consumer is untouched.
A test pins both.

30/70 DUPLICATION — deliberately NOT built, and this is the honest half.
Measuring it needs main-content extraction (strip nav/header/footer). Without
that, comparing two same-template pages returns ~95% similar for every site —
a confident false positive, which is exactly the failure class the rest of
this branch exists to remove. It stays an explicit LLM judgement over the >=3
same-family pages C1c now samples for it, labelled as judgement, never quoting
a similarity percentage nobody computed. A wrong number would be worse than
the current honest gap.

The two must not be merged in the report either: cannibalisation is a SERP
fact Google measured; 30/70 is a content question. The spec now says so.

Verified: fixture with 3 pages on one query, 2 on another, 1 on a third →
2 conflicts, correct ranking, single-page query excluded; live dispatch
degrades cleanly with no account; seo-data 110 -> 119 pass, 0 fail; full suite
green; shellcheck + py_compile clean.
2026-07-17 11:55:00 +02:00
Bastien Chanot 2de58faa38 feat(seo-data): C1b — sitemap verb, the denominator COVERAGE never had
I5 made a COVERAGE line mandatory in STEP 9 and told the agent to "count the
URLs in sitemap.xml" without giving it a command. STEP 4 only ever did
`curl … | head -50` — a preview, not a count. This closes that.

fetch.sh sitemap --url … → {count, urls[], index, dropped}. Stdlib only
(urllib + xml.etree + gzip): no auth, no Google, no venv, so it runs wherever
the mock/degrade paths run. Follows <sitemapindex> one level, dedupes, strips
whitespace, handles .xml.gz. Every cap REPORTS what it cut (children_skipped,
truncated) rather than truncating silently — same rule as COVERAGE itself.

PLAN CORRECTION: the proposal said the verb would "validate each URL via the
H1 guard". Wrong. urllib fetches these, so nothing here reaches a shell and
there is no injection surface to guard. The guard belongs at the point of
use, where seo-analyzer interpolates a URL into curl — which is the contract
the sameAs check already established. A second copy of url-guard here would
only drift from the first. The module carries a garbage filter, named as such.

SECURITY: the security-guidance hook asked for defusedxml. Taken seriously,
not obeyed — it would drag a venv into a module whose whole point is being
stdlib-only. Split the threat instead: xml.etree does NOT expand external
entities (XXE is not the vector), but it IS billion-laughs-vulnerable, and
the 20 MB read ceiling bounds the input, not the expansion. A sitemap NEVER
has a DTD — sitemaps.org is <?xml?> then <urlset xmlns=> — so any
doctype/entity is refused BEFORE parsing, with its own reason
(unsafe_xml_dtd, distinct from parse_failed: it is a finding, not a glitch).
Refusing the construct beats depending on parser internals. Fixture is a real
billion-laughs payload.

Verified against the live target, not just fixtures: zenquality's sitemap
returns count=86, dropped=0, matching `grep -c '<loc>'` on the raw XML
exactly. Dead URL → {"status":"degraded","reason":"fetch_failed"}, exit 0.
seo-data 95 -> 110 pass, 0 fail; full suite green; shellcheck + py_compile
clean.

Note: no config-edit sentinel was needed after all — config-protection guards
lib/tests, not lib/seo-data. I posted one, found it uncommitted-and-unconsumed
afterwards, and removed it rather than leave an open one-shot gate lying
around. Worth knowing: seo-data.test.sh is 110 assertions and is NOT covered
by that hook, while lib/tests/*.test.sh is.
2026-07-17 11:28:00 +02:00
Bastien Chanot 8dcdc661ce fix(seo,geo): C1a — find sees build output, grep does not; the two disagreed
Surfaced by dogfooding on a real Astro repo instead of reading the spec.

Claude Code installs a shell function routing `grep` to ugrep with
`--ignore-files`, so grep honours .gitignore and never descends into a
gitignored dist/. `find` honours nothing. seo-analyzer uses both.

Measured on zenquality (Astro, dist/ gitignored but built locally),
seo-analyzer.md:497 returned 92 images, 45 of them under dist/ — every asset
listed twice, source and generated copy, byte-identical. Two live
consequences:
- "top 20 by size" was ~10 real images dressed as 20.
- Batch C (`cwebp -q 80 <img> -o <img>.webp`) could target dist/og-image.png;
  the .webp lands in dist/ and the `npm run build` the dispatcher runs to
  VERIFY the fix erases it. Fix lands, verification passes, nothing survives,
  report says applied.

lib/source-scope.sh separates source from build output, framework-aware.
`public/` is deliberately NOT excluded by default: it is Astro/Vite/Next
SOURCE and holds favicon.ico, apple-touch-icon.png and robots.txt — the very
files STEP 4 curls. It is build output only for Hugo/Gatsby, detected from
config (legacy config.toml alone is ambiguous, so it needs archetypes/ too).
Blanket-excluding it would blind the audit to its own resource checks.

findargs emits one token per line and MUST be consumed via a quoted array.
I shipped a flat-string version first and the dogfood caught it: the shell
globs */dist/* against the CWD and passes the matches to find as search
paths, which turned 90 hits into 135 and kept every dist/ file. Both the
header and a functional test now pin that.

Also: no bundle item may target build output, in either agent. That is the
real safety net — even if some future find leaks a dist path, the fix cannot
land there. Fix the source that generates the artifact; if the source cannot
be found, that is a finding, not a reason to patch the artifact.

SCOPE CORRECTION: the proposal claimed grep was auditing 86 generated files
instead of 9 templates. That was FALSE — the ugrep shim already skips them.
Killed my own premise before coding it; the real bug is narrower and lives in
find only. Do NOT "fix" the grep lines to match: they are already correct,
and adding these exclusions there would drop public/.

Verified: 90 -> 45 images on the real repo, 0 dist survivors, public/
preserved (favicon.ico still visible); 34 new assertions PASS / 0 FAIL; full
suite green; shellcheck clean. Test file addition used the documented
one-shot config-edit sentinel.
2026-07-17 09:50:57 +02:00
Bastien Chanot 7d6aa09faf feat(lib): H1 — url-guard, shell-injection + local-target refusal before curl
Prerequisite for C1, which is why this moved up from AXE 5. Today $DOMAIN is
typed by the operator and interpolated into ~10 curls (seo-analyzer.md:254+,
geo-analyzer.md:248+) — self-inflicted risk. The sitemap crawl changes the
threat model completely: URLs then come from the TARGET'S OWN SERVER, so a
remote file's bytes reach a shell.

The severe hazard is injection, not SSRF. Those curls quote with ", inside
which $ and backtick still execute, and ~/.claude/.env holds
GOOGLE_OAUTH_CLIENT_SECRET + CRUX_API_KEY. A <loc> of
`https://x/$(cat ${HOME}/.claude/.env)` reads the vault into a request. The
test suite asserts exactly that payload is refused.

Code, not prose: a markdown instruction does not stop an injection. Mirrors
the house pattern (fetch.sh:25 _label_safe) — whole-string allowlist, C
locale, POSIX case: newline-proof, locale-independent, no grep pitfall.
Allowlist over denylist per CLAUDE.md.

Covers: shell metacharacters; scheme (http/https only — no file:, gopher:);
literal loopback/private/link-local/metadata/.local; userinfo authority
confusion (https://trusted.com@127.0.0.1/ hits .0.0.1, not trusted.com).

NOT covered, stated in the header rather than left silent: DNS-level SSRF. A
public hostname resolving to a private address passes. Closing it needs
resolve-then-pin at the HTTP layer; shell curl cannot without a TOCTOU
window. Proportionate to the threat model — this runs on a workstation
auditing the operator's own client sites.

Wired at all three entry points: both agents' STEP 4 domain assignment, and
the W3 sameAs loop (whose URLs come from the audited repo, not the operator).
Refused sameAs rows report as REFUSED rather than vanish — neither dead nor
live, and an unguardable sameAs is itself a finding.

Note: writing the test file tripped the config-protection hook (test suite is
a guarded quality-gate). Used the documented one-shot sentinel with a reason
rather than working around the gate; it was consumed as designed.

Verified: 47 new assertions PASS / 0 FAIL, picked up by make test; full suite
green; shellcheck clean on lib/url-guard.sh (the sole remaining hit in the
health-stack glob is pre-existing, lib/gitflow-test.sh:242); guard dogfooded
against the real zenquality.fr domain (accepted) and the real exfil payload
(refused, exit 2).
2026-07-17 09:25:34 +02:00
Bastien Chanot a6d423b940 feat(seo-data): W1 — surface rich_results, the data inspect already threw away
google_seo.py:129 read only indexStatusResult out of the URL Inspection
response and discarded the rest. richResultsResult was already on the wire:
same call, same OAuth scope (webmasters.readonly), same quota. Google's own
structured-data verdict on the live indexed URL was being downloaded and
binned.

Plan correction: the TODO said "richresults verb". Wrong — a new verb means
a second POST to the same endpoint for a payload already received, on a
per-site quota, and nobody wants rich results without index status. Extended
inspect() instead; fetch.sh unchanged, no new verb, no new scope.

Design driven by the published schema, not by guesswork — two details I
would have got wrong:
- richResultsResult is OMITTED when Google detects none ("absent if none
  found"). Surfaced as synthetic verdict ABSENT rather than a missing key: a
  caller cannot tell an absent key from a check that never ran. ABSENT means
  "none detected", never "invalid". The KeyError path is the real risk here,
  so it has its own fixture dir (fixtures-norich/) and its own tests.
- PARTIAL is "Reserved, unused" per the API docs. The draft emitted it. It
  never emits it now, and a test asserts the absence.

issues[] deduped (the same issueMessage repeats across every affected item),
errors/warnings count instances — scale from the counter, cause from the
message.

seo-analyzer STEP 4 consumes it as the system's only programmatic JSON-LD
validation, bounded honestly: index:inspect is per-URL, quota'd, and needs a
verified property, so its reach is the STEP 9 COVERAGE ratio, not the site.
Replacing a fake validator with a fake coverage promise would be no better.

This is what beats claude-seo: their README's "dual validator (Rich Results
Test + Schema Markup Validator)" is two hyperlinks a human clicks — grep of
their .py finds zero calls. This is Google's verdict, via auth already held.

Note: the new dedupe assertion trips SC2015 (A && B || C), same as the
pre-existing line 27; ok() ends on an assignment so it cannot fail. Kept for
house-style consistency — lib/seo-data/*.sh is outside the lib/*.sh
shellcheck glob anyway.

Verified: seo-data 85 -> 95 pass, 0 fail; both paths exercised end-to-end
and output inspected by hand; make test 35 GREEN / 0 RED; py_compile clean.
2026-07-16 20:51:30 +02:00
Bastien Chanot 096418c3e7 feat(capitalize): auto-persist memory to develop on /close + /capitalize (STEP 5C, BDR-068) 2026-07-16 13:51:15 +02:00
Bastien Chanot 890e55f789 fix(model-routing): ronde edge fixes — feater applier carve-out, /refactor→sonnet dispatch, /analyze gate, audit-pin guards (F1-F5) 2026-07-16 12:45:07 +02:00
Bastien Chanot 1947a21237 fix(model-routing): wave-4 review fixes — forward DEPLOY_HINTS + SKIP_SEO in PACKAGE, realign §7/§8 annex numbering (I1/I2/I3) 2026-07-16 12:12:53 +02:00
Bastien Chanot fe1d60fccb chore(model-routing): wave-4 census + docs + BDR-066 (client-handover doc-gen → sonnet) 2026-07-16 11:56:54 +02:00
Bastien Chanot bed695a6c6 chore(model-routing): wave-3 census + docs + BDR-066 update (bugfix/code-clean split) 2026-07-16 10:34:12 +02:00
Bastien Chanot 1f7afc1d49 feat(model-routing): /bugfix split — reflection inline, bugfixer = sonnet executor (supersedes BDR-050 bugfix carve-out)
Reroute hotfix's deeper-bug escalation to the /bugfix skill (bugfixer is now a
pure executor, not loadable standalone). loops-light locks repointed to the
bugfix orchestrator + bugfixer-executor shape.
2026-07-15 23:50:34 +02:00
Bastien Chanot bc8eede090 chore(model-routing): wave-2 census + docs + BDR-066 update 2026-07-15 21:49:34 +02:00
Bastien Chanot 45cd86810a feat(model-routing): /hotfix split — reflection inline + gate, hotfixer = sonnet executor (dual-use applier preserved) 2026-07-15 19:28:58 +02:00
Bastien Chanot bd5a603567 test(model-routing): census guard — gate wiring, pins, executor shape (flip-tested) 2026-07-15 11:49:59 +02:00
Bastien Chanot 56c451ea25 feat(model-routing): /feat re-architecture — reflection inline, feater = sonnet executor (partial supersede BDR-050) 2026-07-15 11:33:18 +02:00
Bastien Chanot f2dd361bd5 feat(model-routing): blocking model-gate include (self-check + witness) 2026-07-15 11:06:49 +02:00
Bastien Chanot 9984b75f90 feat(model-routing): model-check witness (big/small/unknown) + flip-tests 2026-07-15 11:00:32 +02:00
Bastien Chanot e9a38a0268 fix(memory): test covers CLAUDE.global.md guard entry; doctor asserts exact global symlink target 2026-07-14 16:19:29 +02:00
Bastien Chanot c20ad4763a feat(memory): guards, doctor stats and doc-commit exclusions follow CLAUDE.global.md 2026-07-14 03:55:25 +02:00
Bastien Chanot 8bf7459566 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.
2026-07-10 12:38:32 +02:00
Bastien Chanot caa5bed189 fix(seo-data): source ~/.claude/.env in make seo-connect so OAuth creds reach connect.py
The seo-connect target ran connect.py without sourcing ~/.claude/.env, so
GOOGLE_OAUTH_CLIENT_ID/SECRET (documented to live there) never reached
os.environ — connect.py aborted telling the user to set what they had set.
Mirror fetch.sh's sourcing; add a regression lock.
2026-07-10 03:17:06 +02:00
Bastien Chanot cbfd89d6ff docs(seo-data): correct README status enum + doctor/link/queries accuracy 2026-07-10 02:36:33 +02:00
Bastien Chanot c50d2cc5bb docs(seo-data): engine usage + security contract README 2026-07-10 02:29:48 +02:00
Bastien Chanot 15962fcd90 feat(seo): wire GSC+CrUX data into /seo FULL (STEP 0 account select, CWV field, GSC perf) 2026-07-10 02:19:57 +02:00
Bastien Chanot c4bee6aad3 chore(seo-data): install/make/doctor wiring + gitleaks allowlist for token store 2026-07-10 02:10:31 +02:00
Bastien Chanot 7f06533d8b feat(seo-data): OAuth consent + property discovery + pinned deps 2026-07-10 01:45:33 +02:00
Bastien Chanot 39e227f1c8 fix(seo-data): fail-open CLI contract (corrupt store + bad usage always emit JSON) 2026-07-10 01:42:23 +02:00
Bastien Chanot c3a504fbbf feat(seo-data): fetch.sh entrypoint with venv/system fallback and redaction 2026-07-10 01:32:48 +02:00
Bastien Chanot 5a318076fd feat(seo-data): GSC Search Analytics + URL Inspection with lazy OAuth refresh 2026-07-10 01:26:30 +02:00
Bastien Chanot 493ecd8806 fix(seo-data): extract real CrUX origin on 404 retry + drop dead import 2026-07-10 01:23:13 +02:00
Bastien Chanot e214da036d feat(seo-data): CrUX field-data fetch with mock mode and graceful degrade 2026-07-10 01:16:11 +02:00
Bastien Chanot 0f7fd5b678 fix(seo-data): re-assert store dir 0700, chmod lock, drop dead import 2026-07-10 01:11:49 +02:00
Bastien Chanot fb0484954a feat(seo-data): label-keyed atomic OAuth token store 2026-07-10 01:03:50 +02:00
Bastien ChanotandClaude Opus 4.8 5a1fff5030 chore(skills): drop find-skills — unused, and its update step began timing out
RC fix (soak day 1): `make update` step 7.5 failed on a 300s clone timeout
of alchaincyf/find-skills. The skill (search the skills.sh registry from
Claude) was never used; the discovery case stays reachable manually via
`npx -y skills find <query>`. Removed from install-plugins.sh (install list
+ summary + comment), update-all.sh (refresh list), link.sh
(NPX_EXTERNAL_SKILLS), lib/toggle-external.sh (MANAGED_TOOLS + case arms),
plugin-advisor.md, .gitignore; local skills/find-skills symlink deleted.
Memory-registry and test-fixture mentions kept — append-only history.
toggle-external `list` verified post-removal; suites 8/8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpphkdTosUzokBDNG7PToS
2026-07-08 17:11:11 +02:00
Bastien Chanot 4e83f39a70 test(guards): add anti-partial-fix regression guards (fil rouge)
lib/tests/run-review-guards.sh — 5 whole-surface guards that RED if a banned
pattern subsists anywhere, auto-run by make test (run-*.sh glob):
  G1 trailer (A1), G2 false CLAUDE.md attribution (A5), G3 strict-YAML frontmatter
  (A4), G4 reconcile hermeticity (job3 B1), G5 hook-drift installed==emit (A2).
This is the check that would have caught A1/A4/A5/A2 at make-test time instead of
an adversarial review — the series' recurring failure was fixing one instance and
leaving twins. G3/G5 degrade to SKIP if pyyaml/emit-hook absent (portability).
Teeth verified: a planted trailer in a real agent REDs G1. Review fil rouge.
2026-07-08 15:48:51 +02:00
Bastien Chanot 17bdd08b43 job7 step C: gitleaks backstop — .gitleaks.toml, pre-commit hook, make scan-secrets
Pre-commit (lib/gitflow.sh emit-hook) now runs `gitleaks git --staged` right
after the root-commit/merge-in-progress guard, on ANY branch — not gated by
branch protection, since secrets shouldn't land anywhere. Non-blocking if
gitleaks isn't installed (warn + pass). gitleaks 8.30.1: `protect` isn't
listed in --help anymore (still runs, but undocumented) — used the
documented `git --staged` equivalent instead.

.gitleaks.toml allowlists the 3 false-positive classes from the job7 triage
(marketplace.json 40-hex "sha" fields, superpowers ws-protocol.test.js nonce,
git-game test-secret-* fixtures) plus a 4th entry for ~/.claude/.env itself —
not a false positive, but scanning our own canonical vault (BDR-026) is pure
noise for a tool meant to catch stray copies. All 4 verified empirically
against the real flagged files/values before being added, not assumed from
gitleaks' docs.

`make scan-secrets` scans this repo's git history + ~/.claude (dir scan),
redacted JSON to .audit/ (verified: --redact scrubs Match/Secret in the
report itself, not just console logs — safe to commit). Repo: 0 findings.
~/.claude: 18 remaining across 8 files — 5 match the known job7 triage
(pending the GO-gated purge in step D), 3 are new discoveries outside the
original triage scope (flagged for the user, not characterized further —
never read a flagged file's content past what gitleaks' redacted report
gives you).

lib/gitflow-test.sh T16: fake secret on a feature branch (not main/develop)
→ blocked, proving the check isn't gated by branch protection; clean commit
passes; PATH without gitleaks → warns and still commits. 96/96 green.
2026-07-07 12:47:06 +02:00
Bastien Chanot b9300c3382 job7 step A: MAGIC_API_KEY by reference, not by value (BDR-026 follow-up)
toggle-external.sh's `claude mcp add magic --env API_KEY="$MAGIC_API_KEY"`
materialized the key as plaintext into ~/.claude.json — a copy outside the
~/.claude/.env canonical, invisible to the repo's gitignore/allowlist reach.
Claude Code supports ${VAR} expansion in mcpServers config (docs confirmed),
so the fix is a reference, not a scrub.

- lib/toggle-external.sh: --env 'API_KEY=${MAGIC_API_KEY}' (single-quoted
  literal reference, not bash-expanded) so future `enable magic` runs write
  the safe form too.
- README: new "Adding an MCP server that needs a secret" section documenting
  the --env pitfall and the wrapper pattern.

Out-of-repo companion changes (not in this commit): ~/.bashrc gained a
scoped claude() wrapper that sources ~/.claude/.env into a subshell before
exec'ing the real binary (verified: the var never reaches the ambient
interactive shell, only claude + children) — chosen over a global export to
keep the secret's surface minimal. ~/.claude.json's mcpServers.magic.env.API_KEY
was rewritten to the same "${MAGIC_API_KEY}" reference via a surgical jq
edit (never read directly, so the value never entered this session's
context). The 2 of 5 rotating ~/.claude/backups/.claude.json.backup.* files
still holding the old plaintext were scrubbed the same way.

Residual: this session predates the bashrc wrapper, so `claude mcp list`
currently warns "Missing environment variables: MAGIC_API_KEY" — expected,
resolves on next terminal + Claude Code restart. MAGIC_API_KEY rotation
still pending (user action, after this commit).
2026-07-07 12:37:29 +02:00
Bastien Chanot 3340c7d1bd job7 step B: redact printenv/env dumps in rtk-rewrite.sh (GITEA leak vector)
Any single-pipeline printenv/env dump now gets a redaction pipe appended
before it can reach stdout/transcript; `env VAR=x cmd` (legitimate
subprocess launch) is left intact. Compound commands (;, &, ||) bail
untouched — appending the pipe at the end would attach to the wrong
segment.

Discovered mid-implementation: rtk rewrite classifies any command
containing "env" as exit-code 2 ("deny"), with no settings.json rule
backing it — the command still reaches native evaluation and can run.
Adjusted case 2/1 handling so the redaction check runs regardless.
2026-07-07 12:30:30 +02:00
Bastien Chanot da3abf9f1b job5: J5-13 delete pending verbs (v2 hook rejected by BDR-037, J4-17 moot) 2026-07-07 00:53:49 +02:00
Bastien Chanot 5fc38e74e6 job5: J5-10 delete plugin_enabled (last caller replaced at 6d72d0a) 2026-07-07 00:14:06 +02:00
Bastien Chanot 3c796ade9c job5: J5-04 delete detect_security_guidance (born dead at 45c3507 re-add) 2026-07-07 00:13:07 +02:00