Security Audit

Skill packs › Skill

Security AuditIN EVERY PACK

Continuously verify that a website is still the site you published — no injected spam, no rogue admin, no hidden plugin, no different page served to Googlebot than to humans. Use to stand up monitoring on a new property, to run a point-in-time compromise check, to investigate a “my site looks hacked” report, or as the daily read-only sweep across a network of sites. Read-only by default; it finds and proves, it does not clean.

Skill file security-audit.md · last updated Aug 14, 2026

How to run it. Download any pack from the skill-pack directory, unzip it into your Claude project folder, and this skill is one of the files inside. You do not paste it anywhere — the agent reads it when the job calls for it. This one ships in every pack.

Use this when you are responsible for a site staying the site you published — which, on a managed network, is every day, not the day someone complains.

This skill exists because of a specific pattern we kept losing to: we found out from the outside. A first-degree LinkedIn connection messaged at 6:19am asking whether we had been hacked. A Search Console email about video markup turned out to be three sites serving a spam storefront. A client forwarded a newsletter about a WordPress vulnerability and asked whether it affected his sites — it did, and the sweep that question triggered found more. Every one of those was discoverable from outside, for free, with no credentials, on any morning before it was reported to us.

The one idea

A site is compromised the moment it stops matching its own baseline — not the moment somebody notices.

So the job is not “look for malware.” Malware you have never seen is invisible to you. The job is to write down what the site is when it is known-good, and then diff it every day. A new external script domain is suspicious whether or not you recognise the payload. A new admin user is suspicious whether or not you know what they did.


The seven checks

All read-only. All runnable against any WordPress property with a REST endpoint and an application password. Six can run credential-free from outside.

# Check What a failure looks like
1 External resource inventory A script, iframe or link to a domain that was not in yesterday’s baseline
2 Spam-injection markers Known link-spam domains, casino/pharma keyword blocks, hidden-link patterns in the rendered HTML
3 Users A new account, or an existing account whose role escalated
4 Plugins A new or removed plugin, a status flip, a version downgrade, or a version that is not a real published release
5 Reachability A critical page that stopped returning 200
6 Cloak check The page served to Googlebot differs from the page served to a human
7 Did checks 3 and 4 actually run? An unreadable REST endpoint, an unparseable 200, or an empty baseline

Check 7 is the one that makes the other six trustworthy

An endpoint that 403s and a site with zero plugins produce the same empty list. Without a guard, “we could not read the plugin list” silently renders as “no plugins changed” — the monitor reports clean on the day it went blind. So: an unreadable endpoint is its own ALERT, naming the real cause, and that half of the diff is SKIPPED — never reported as mass deletion, and never allowed to pass as clean.

This generalises past security. Any check whose failure mode resembles its success mode needs a third state.


Three states, never two

The hardest bug in this whole skill is the two-valued answer. When the monitor asked wordpress.org “what versions of this plugin exist?”, a None reply meant two opposite things: the plugin is premium and not listed there (fine, forever) and wordpress.org did not answer (a real blind spot). Both printed the same alert line, so a paid plugin alerted every single morning — and an alert that fires every morning is an alert nobody reads.

The fix is the shape, not the wording:

  • NOT_LISTED — a definitive answer (404, or a 200 whose body is false). Advances the

baseline, logged as INFO, and states plainly that the version was not verified upstream.

  • UNREACHABLE — timeout, 5xx, unparseable. Still ALERTs, names the upstream service

rather than accusing the plugin, and retries with backoff so one network blip cannot manufacture a security alert.

  • A real version set — compare normally.

And delete the hand-maintained exception list. We had a PREMIUM_SLUGS constant listing which plugins to excuse; hand-maintained lists drift behind the thing they describe. wordpress.org answers the question itself, so ask it.


Prove the monitor can fail

A check that cannot fail is not a check. Before you trust a clean result, run live negative tests against the real baseline in memory (never writing to the baseline file):

  • control → expect 0 alerts
  • inject a rogue admin → expect exactly 1
  • remove a plugin → expect exactly 1
  • tamper a version → expect exactly 1

Only then is “clean” an earned result. When we did this the first time, the test suite went from 55 to 116 assertions and immediately caught a defect the live path would never have reached: the new retry branch called time.sleep() and time was never imported — code that only executes during a wordpress.org outage, which is exactly when the monitor matters most.


What the real incidents taught

A hidden plugin is not in the plugin list, by design. One backdoor shipped as “Web Media Optimizer” and filtered itself out of all_plugins, stripped its own action links, and removed itself from update checks. A clean plugin audit meant nothing. It was found by requesting its file path directly and comparing the status code against a control path that should not exist: real file → 200, control → 307. Probe for the thing, do not ask the list.

Payload and repair are two halves. The same compromise had a dropper in mu-plugins/ and a hidden fake plugin. Removing one and declaring victory is how a backdoor self-heals.

Cloaking is invisible from a browser. Three sites answered 500 to humans and 200 with a spam storefront to Googlebot. Nobody clicking around would ever see it.

Not every scare is an incident, and saying so is the deliverable. A “lickfix malware” report turned out to be a Cloudflare bot-challenge interstitial. Sixteen independent checks came back clean and the honest answer was no active malware. An audit that can only conclude “compromised” is not an audit.

Rate-limit yourself. Chained manual runs against one host earned a 429 that got logged as an ALERT. Space runs out, and annotate self-inflicted noise in the log immediately so nobody reads it as an incident six weeks later.


Running it across a network

  1. Baseline every property the day you take it on. No baseline, no monitoring — and say

so out loud rather than implying coverage you do not have.

  1. Run daily, read-only, from a residential IP with a full browser User-Agent. Datacenter

IPs and minimal UAs get edge-blocked and you will audit the WAF instead of the site.

  1. Alert on change, log on clean. The log is the evidence that the quiet days were checked.
  2. Never auto-remediate. Writing to a compromised site destroys evidence and usually does

not reach the interceptor anyway. Diagnose fully, package the fix, hand it to a human with host access.

  1. Count what you do not cover. If the network is 198 sites and you can enumerate 49, the

report says 49 of 198 — not “the fleet is clean.”

Definition of done

  • Every monitored property has a dated known-good baseline in version control.
  • Every check has a third state for “could not determine.”
  • The negative tests pass — the monitor demonstrably catches a planted change.
  • The uncovered count is stated in every report.
  • No cleaning was performed by the audit itself.

Learned in the field

Appended automatically by the self-improvement loop (Skill-Learnings/): dated lessons from real runs. Newest at the bottom.

<!– learning:2026-08-02-one-message-for-two-opposite-facts –> August 2, 2026 (from: sigrun.com security monitor — a paid plugin alerted every morning forever because “not listed” and “unreachable” printed the same line)

When one code path can produce a message for two opposite facts, the message is wrong in both cases

The sigrun.com monitor verifies each plugin version against api.wordpress.org. Its lookup returned None for two situations that have nothing in common:

  • wordpress.org answered, and it does not distribute this plugin — true of every paid add-on (Elementor

Pro, Yoast Premium, WPConsent Premium) and of the site’s own custom plugin.

  • wordpress.org could not be reached at all — a timeout, a 5xx, a WAF interstitial.

Both printed upstream UNVERIFIABLE ... lookup failed. One sentence, two opposite meanings, and the failure runs in both directions:

  1. It never clears. A paid plugin nobody had hand-added to the PREMIUM_SLUGS allowlist alerted every

single morning, forever, and the only way to silence it was for a human to edit a hardcoded set. That is alert fatigue attached to a scheduled job. This monitor exists to catch the next infection on day one — and a daily alert everyone learns to skim rebuilds the exact condition it was built to remove.

  1. It hides the real thing. During a wordpress.org outage, every ordinary plugin bump prints that same

“UNVERIFIABLE” line. A genuinely tampered plugin folder arriving in that window would have been visually identical to the routine noise. The one line a human most needs to trust said the same thing whether the news was “nothing to see” or “someone edited your plugins.”

Rules:

  1. Distinguish “answered no” from “did not answer.” A 404 is data. A timeout is the absence of data. Any

function that collapses them into one return value has thrown away the more important half. Return a three-state result, not a nullable one.

  1. A hand-maintained allowlist is a clock that runs slower than the thing it describes. PREMIUM_SLUGS

had two entries and the site had four unlisted plugins. Derive the answer from the authority (wordpress.org already knows) instead of restating it locally.

  1. Retry before you alarm; vary time before you vary anything else. A one-second network blip should not

be able to manufacture a security alert. Backoff-retry the unreachable case, then report it.

  1. Dispatch on type and fail CLOSED. The sentinel chain if known is UNREACHABLE ... elif nv in known

would substring-match on a sentinel ("1.1" in "NOT_LISTED") or raise TypeError on None if identity ever missed. Check the shape of the good case first and let everything unexpected fall through to the alert branch — a security check must never be able to pass by accident.

  1. Test the path that only runs during the emergency. The new retry code called time.sleep() with time

unimported. It executes only when wordpress.org is down — i.e. only when the monitor matters — so no live run would ever have caught it. Any branch that fires only under failure conditions needs a test that simulates those conditions, because production will never rehearse it for you.

  1. Prove red before you trust green. Reconstructing the pre-change code and running the new suite against

it produced 17 failures and a TypeError. Without that step, 116 passing assertions prove only that the tests agree with the code that was just written.

Learned August 2, 2026.

<!– learning:2026-08-03-a-capability-with-no-skill-file-cannot-propagate –> August 3, 2026 (from: SEO-audit discoverability + security-audit generalization build — 49 published SEO audits with no hub, a rubric that lived inside one task’s parameters, and a jammed harvest queue that turned out to be the same problem)

A capability with no skill file cannot propagate, cannot be taught, and cannot absorb its own lessons

Dennis asked to be “clearly known for doing SEO audits.” The assumption going in was that this was a marketing problem — write something, publish it. It was not. An inventory of our own properties found 49 published SEO audits already live, plus 341 audit-family URLs across three domains. The work existed. What did not exist was any way to see it as a body of work: no hub page, 38 of the 49 with zero inbound links from any sibling audit, 35 with zero outbound links. Forty-nine deliverables, each an island.

The root cause was one level deeper than the missing page. There was no seo-audit skill file. The seven-component SEO & Growth rubric — the thing that makes two audits comparable — existed only inside the parameter block of a single scheduled task (wtp-monthly-seo-reaudit). One job could score a site. Nothing else could, because there was nowhere else to read the definition from.

And that had a visible symptom nobody had connected to it: three learning notes had been jammed in the harvest inbox since the previous day, all naming seo-audit, all unresolvable, aging toward the stale-queue gate. The morning run reported them as a queue defect. They were not a queue defect. They were the loop correctly reporting that a skill our own runs believed in did not exist. A jammed learning note is a missing-capability alarm, not a filing error. Creating the skill cleared all three on the next run.

Same shape on the security side: seven real checks, a 116-assertion test suite, and a track record of caught compromises — all of it existing only as one client site’s monitor.py. Nothing generalized to the network because there was no file to generalize into.

The rule: when you find yourself doing something well and repeatedly, check whether it has a canonical skill file. If it does not, that is the deliverable — before the landing page, before the marketing. The file is what lets the capability propagate to every pack, teach itself to the next agent, and accumulate lessons. Publishing a page about a capability with no skill file behind it produces a claim; publishing the skill produces a system.

Second-order effect worth expecting: adding two mandated skills tripped every coverage gate that had been built in the preceding days — the SOMBA orphan check, the numbering lists, the count derivations. All of them fired correctly and named exactly what to edit. Ten registration points across three builders, caught by gates rather than by users. That is what those gates are for, and a day where several fire at once is a good day, not a messy one.

Corollary — measure your own work before describing it

Before writing a word of the hub page, the 49 audits were fetched anonymously and measured: status, transfer size, time to last byte, inbound and outbound sibling links, h1, meta description, JSON-LD. That is our own seo-audit skill run against our own SEO audits, and it produced the specific numbers the page and this note are built on — including one page taking 9.9 seconds to load and three returning 403 to every programmatic client (browser-verified fine, so: invisible to AI crawlers, visible to humans).

Running the skill on yourself first is not a nice touch. It is how you find out whether the claim you are about to publish is true.

<!– learning:2026-08-03-a-compromised-site-must-not-outscore-a-clean-one –> August 3, 2026 (from: weekly-fleet-hub-audit v2, fleet-wide proof enrichment)

Rankings are evidence about someone’s work — check whose before you score them

The fleet scoreboard rates every site on PROVE: Domain Rating, organic traffic, and the breadth of keywords it ranks for. On August 3, 2026 the two sites whose keyword breadth looked strongest were philmershon.com (15 ranking keywords) and theathletespotlight.com (5). Both readings were the attacker’s, not the client’s.

Pulling the keywords themselves rather than the count showed philmershon.com — a speaker coach — ranking for hollymoviehd, borat thong, nintendo store, jupiter 125 black colour, silver aranjanam for baby boy. Fourteen of its fifteen keywords were junk. On theathletespotlight.com it was five of five: activa 6g best colour, bici decathlon, charola de unicel. Selecting best_position_url alongside the keyword named the cause — every junk term ranked on an injected path:

/product-similar-image/?<digits> /product/category/<digits> /shop/manufacturer-site?&transition=top<digits>

with a per-site numeric suffix (…1310 on one, …1760 on the other): one kit, two of our sites. Uncorrected, philmershon.com scored impact 40; netting the injected rankings out drops it to 21 — an eight-point BIS swing. A compromised site was being rewarded for being compromised, and would have been reported as a fleet-best performer.

Rules:

  1. Never score a ranking you have not attributed to a URL. org_keywords is a count of

things Google associates with the domain, not a count of the client’s wins. Select best_position_url and read the paths before any keyword number reaches a score or a report.

  1. Net hostile rankings out of the score and raise them as an action instead. Traffic

attributed to injected URLs gets discounted in the same proportion. Infection is a dispatch item, never a credit.

  1. Judge the keywords by fit with the person, not by how spammy they look. `nintendo

store` is a fine keyword — for a games retailer. The tell is a speaker coach ranking for it. The GCT already states who each site is for; compare against that.

  1. A clean sitemap and a clean REST API do not mean a clean site. Both sites’ sitemaps

and post lists were entirely legitimate, and their real content is real. The injection lives beside WordPress, in URL space the CMS never enumerates — so any check that walks the sitemap or /wp-json/wp/v2/posts is structurally unable to find it. What Google has indexed is a separate source of truth from what the CMS will admit to.

  1. 404 today does not mean clean. These URLs now return 404 to human and Googlebot

alike from a datacenter IP, while still ranking. That is consistent with cleaned-but- still-indexed and with a cloak keyed to something the probe can’t reproduce. Say which of those you have ruled out; removal still has to be requested in Search Console either way, because the junk keeps ranking after the files are gone.

Companion to the same day’s classify-the-metric-dont-just-count-it (referring domains, same disease one metric over): fleet median referring domains is 368 against a median of 26 dofollow, because a .shop/.store link-spam blast hits every site daily. Report refdomains_dofollow; refdomains is noise. billybatt.com reads as 324 referring domains and is actually 2 dofollow, both of them ours — the authority problem the number appears to have solved is entirely intact. Ahrefs exposes an is_spam flag; use it.

Learned August 3, 2026.

<!– learning:2026-08-05-a-rotation-announced-is-not-a-rotation-applied –> August 5, 2026 (from: skill-pack-propagation daily run)

The push failed with a 403 and the fix had been sitting in the thread for two days — we were presenting a superseded credential

The daily run’s ledger push to git.adectra.com/blitzmetrics/agent-runtime returned 403 “You are not allowed to upload code.” The instinct — and the first draft of the escalation — was “the remote changed, ask a human to restore our access.”

That was wrong, and one rule caught it: an auth error tells you the credential presented was refused, never that it was the credential you meant to present. Proving which one went over the wire took three checks and about a minute:

Check Result What it ruled out
git ls-remote succeeds not a network/host/outage issue
GET /personal_access_tokens/self active, not revoked, write_repository, expires 2027-07-20 not an expired or revoked token
push to a non-master ref same 403 not branch protection — receive-pack is refused outright
token created_at 2026-07-21 17:27 ← the answer

That creation timestamp is three minutes before the email that originally delivered it. Two days earlier, in the same thread, the person who owns those tokens had written: “I have created new GitLab tokens and shared them with you via LastPass. Let me know when you’ve swapped those and I’ll remove the old tokens.” He had begun narrowing the old ones. We were still presenting the July token, so it read (leftover role) and could not push.

Rules:

  1. A rotation announced in prose is not a rotation performed. “New credentials are in

the vault” is a message, not a change. Until someone swaps the value and a real call succeeds with it, the system is running on the old one.

  1. Make the swap a task with an owner and a due date, not a sentence in a thread. The

half-completed rotation is the dangerous state: the old credential still half-works, so nothing fails loudly until something does, days later, in a scheduled job at 4am.

  1. Ask the vendor’s API who you are before you ask a human what changed. Name, scopes,

created_at, revoked — four fields that convert “the remote is broken” into “our copy is stale,” and they are readable with the very credential that is failing.

  1. created_at on a credential is a diagnostic, not trivia. Compare it against the last

rotation you were told about. If it predates that conversation, stop debugging the remote.

  1. Escalate with the answer, not the symptom. The draft that went out says “we never

applied the rotation you told us about, don’t revoke yet, I’ll swap and confirm” — which costs the other person nothing. The draft that nearly went out asked him to investigate a permissions regression that did not exist.

The pattern underneath, which has now happened twice on this exact thread: in July a reply containing both tokens sat unopened for eleven days while our own notes said “waiting on GitLab.” In August a rotation sat unapplied for two days while the job reported a remote failure. Both times the answer had already arrived and nothing was watching the channel it arrived on. A plan that names an external blocker is only as fresh as the last time someone checked the place the unblock would appear — and a job that reports “they refused us” when the truth is “we never updated” sends everyone looking in the wrong direction.

Learned August 5, 2026.

<!– learning:2026-08-09-test-what-wins-the-chain-not-your-entry-in-it –> August 9, 2026 (from: skill-pack-propagation (2026-08-09 run))

When a system resolves something through a priority chain, verifying YOUR entry in the chain proves nothing. Verify what wins.

Step 9 pushes the jobs ledger to GitLab. It exports GIT_ASKPASS so the token is read from .credentials.json and never written into a remote URL, .git/config or ~/.git-credentials. That design is right and it was implemented correctly. It still sent the wrong credential for four days.

Git asks every configured credential.helper FIRST and only falls through to GIT_ASKPASS when they all come up empty. macOS ships credential.helper = osxkeychain in the Command Line Tools system gitconfig, so on this machine the keychain is permanently in front of us. At 2026-08-05 02:09 UTC an internet password for git.adectra.com was written to the keychain under the account claude-audit. From that minute git_askpass.py was never executed — not once — and every push presented claude-audit.

Why three days of evidence pointed at an innocent party. claude-audit has read access, so every diagnostic we owned came back green:

check result what it actually tested
git ls-remote origin 200, refs listed that SOME identity can read
GET /personal_access_tokens/self active, not revoked, write_repository the token we MEANT to send
POST .../info/refs?service=git-receive-pack with our token HTTP 200 the token we MEANT to send
git push 403 “You are not allowed to upload code” the token git CHOSE

Only the last line observes the credential that left the machine. The escalation block printed next to the failure said “403 on push is usually OUR stale copy — compare the token’s created_at against the last rotation you were told about,” which sent the reader after a teammate’s token handoff. That is the second time this job has blamed a colleague for a defect on our side (cf. 2026-08-02, where an askpass sniffed a prompt for a project path the prompt never carries). Both share one root: an auth error tells you the credential presented was refused, never that it was the one you intended to present.

Read the failure message literally, and let it partition the causes. GitLab returns 401 HTTP Basic: Access denied for a credential it cannot authenticate, and 403 You are not allowed to upload code for one it authenticates but that lacks push rights. A 403 therefore rules out “our token was revoked” — a revoked token cannot produce it. The distinction was sitting in the error text on 08-05 and we read past it three times.

The fix, and why it is env and not -c:

export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0=””

An empty credential.helper value resets the inherited chain, so askpass becomes the only source. GIT_CONFIG_* applies to every git process in the subshell — including git invoked from inside a script — which -c on one command would miss. It stores no secret anywhere new, so the “one store, one reader” rule is intact.

Generalize past git. The same shape is everywhere we work: PATH (a shim earlier in it wins over the interpreter you configured), Python sys.path, WordPress filter priority, DNS resolver order, ~/.aws/credentials vs AWS_* env vars vs instance metadata, MCP tool name collisions. In every case the honest question is not “is my entry correct?” but “whose entry is consulted first, and does it answer?” Before debugging a resource you configured, print the resolved chain and find your position in it.

Deleting the bad entry is NOT the fix — the fix is the guard, and here is the proof. Cleaning up afterwards, the stale claude-audit entry was removed and the keychain left empty for the host. One unguarded git push --dry-run later, the keychain held a BRAND NEW entry for git.adectra.com under the account oauth2, created that minute — our project token, copied out of .credentials.json and written to a second location by git itself.

That is standard behaviour and it is the whole trap: on a successful authentication git calls credential approve, and every configured helper stores what worked. So a credential helper is not only a reader that outranks you, it is a WRITER that manufactures the next stale entry. Delete the bad row and the very next successful push re-creates one, which will in turn go stale at the next token rotation and shadow you all over again. Manual cleanup is a fix with a half-life.

The same GIT_CONFIG_VALUE_0="" that stops the helper being READ also stops it being WRITTEN — an empty chain has nowhere to store. Verified: three guarded pushes in a row, the keychain still empty afterwards. So the guard is what keeps “one store, one reader” true, and it is also why no extra test was added for “is the keychain clean?” — that assertion is machine-specific, would be meaningless in the cloud runtime, and is already implied by the helper-list assertion that test_credential_source.py makes.

Generalizes to every cache-on-success credential layer: aws configure writing ~/.aws/credentials, docker login writing ~/.docker/config.json, gh auth login, kubectl context tokens, browser password managers. Ask not only “which store answers first?” but “which store is silently recording my answer for next time?” — because that is the one that will be wrong later, long after you have forgotten it exists.

Coda, same day, same shape, caught before it cost anything. Re-verifying the runner’s unknown-flag contract, zsh run_skill_pack_propagation.sh --bogus 2>&1 | head -6; echo $? printed the refusal and then 0 — which reads as “the guard refuses but reports success,” the exact republish_daily.py al defect. It was not. $? after a pipeline is the exit status of the LAST command, so that was head‘s 0, not the script’s. Unpiped, the script returns 2 correctly. Reading a status through a pipe measures the pipe — the identical mistake as cmd | tee || exit N testing tee, and the identical mistake as testing the credential you configured rather than the one git chose. Whenever you read a value, ask which component actually produced it. One near-miss false alarm is cheap; a false alarm filed as a defect would have sent someone to “fix” a guard that was already correct.

Test contract this produced (tools/test_credential_source.py, gated at exit 99 before the push): assert the effective helper list for the repo is EMPTY under the runner’s exact environment; prove the assertion is not vacuous by planting a helper and watching the same check detect it; and confirm askpass returns the project-correct token by digest, never by printing it. A green run now means what wins the chain is what we configured, which is a claim the previous checks could not make.

<!– learning:2026-08-11-an-alarm-a-file-copy-can-switch-off –> August 11, 2026 (from: fleet-uptime-monitor 10:04Z daily run)

An alarm that a file copy can switch off, and it fails in the alarm’s own direction

blitzadmin_site_cache.json had reported 123.2d ⚠️ STALE in every close-out since 2026-07-27. On 2026-08-11 it would have reported 0.1d, and the warning would simply not have appeared. Nothing refreshed it:

` stat birth 2026-08-11T08:38:36Z == mtime, new inode 214067473 <- CREATED this morning inside the file: cached_at 2026-04-07T06:47:47Z <- 126.2 days old membership vs the April 8 backup: 159 sites, 0 added, 0 removed, 0 key changes `

The age check was (now - os.path.getmtime(path)) / 86400 — a measurement of when the container was last written, published as how old the roster is. That check exists because on 2026-07-27 a 111-day-stale cache was found hiding 18 live client sites behind a green result.

The rules.

  1. **Age a data file by its OWN declared timestamp; mtime is corroboration, never the

measurement.** Any roster, cache, baseline or export that a monitor trusts should carry and publish its generation time. Where the two disagree by more than a day the file was copied, restored or touched — report that as its own verdict (MTIME_MASKED_STALE), not as freshness.

  1. A file with no internal timestamp is UNDATED, not fresh. Publish the number *with

its source attached* — 126.2d [content] vs 10.0d [mtime-only] — so a reader can never mistake one kind of age for the other. targets.json has carried no stamp at all for the whole life of this monitor and nobody knew.

  1. Rank failure modes by DIRECTION, not by size. A false alarm is loud and gets fixed.

This one made a number go DOWN and a warning DISAPPEAR: nothing on screen to question, and the report looks cleaner than yesterday’s. Any defect that removes output is worth more scrutiny than one that adds it, because the reader’s natural reaction to a vanished warning is relief.

  1. A lookup that FAILED must never return a number. Missing file, unparseable JSON,

junk in the timestamp field — all UNREADABLE/UNDATED with age = None, never 0. (Sixth member of this family: empty <title> 08-01, unreachable except 07-29, unreadable-counted-clean 08-02, lookup-miss-returns-zero 08-06, UNKNOWNNO_A_RECORD 08-08.)

  1. Read the declared stamp at TOP LEVEL only. A recursive search finds a per-row

last_seen from today and reports the freshest row as the freshness of the file — a measurement attributed to the wrong subject.

A green verdict is only worth its ability to have been red — and that column is what caught the new detector

The same run shipped a new SPLIT-DNS detector and its first production sweep reported ARMED 0 of 16 — not one verdict could have come out red, on a fleet containing its own known positive. It was reading page bodies from the probe engine’s temp file, which the engine reclaims unless asked for keep_body=True, so every response scored “not a read”. Its 83-assertion suite was green and knew nothing, because every section stubbed the step that was broken.

  1. Publish an ARMED count beside every clean count, for every detector. Had this one

printed only HITS 0 it would have closed an owed item with a detector that cannot detect, and the close-out would have been true and worthless. The armed column is not a footnote; it is the difference between evidence and a green pixel.

  1. **A suite that constructs its own inputs proves the predicate and nothing about whether

the predicate is ever handed those bytes.* Every new detector needs at least one assertion that drives real captured bytes through the real call path with only the *transport stubbed. Engine-side data hiding is invisible to any test that builds its own rows. (Third occurrence: read_robots blanking body on 08-09, and this.)

  1. Reading the failure-mode list is not a control against it. Both defects above are

documented in this very file and were read, in order to run the task correctly, roughly ninety minutes before being shipped again. Only a control is a control.

A verifier that counts must first assert what it is counting

The close-out checker recomputes every published number from the raw artifacts. It reported COMING_SOON got=5 claimed=4 * MISMATCH * — the producer was right; the checker had called len() on a dict (pinned/measured/hits/read/total) instead of on its hits list.

  1. The mismatch was luck, and that is the lesson. The dict happens to have five keys.

With four it would have printed got=4 claimed=4 OK — an agreement computed on entirely the wrong object, recorded as verification and never questioned. len() answers for any sized object, and it answers plausibly. Assert the SHAPE before counting, or an agreement is indistinguishable in the report from a correct one.

Smaller, and cheap to avoid

  1. zsh does not word-split unquoted parameter expansions. python3 deep.py $WL

passed a 19-domain list as ONE argv element and the run probed a single nonexistent hostname. It was visible only because the probe refused to score an unresolved host as anything but coverage loss. Use ${=VAR} or an explicit array, and prefer tools that refuse to produce a clean row from a subject they could not resolve.

  1. A redirect is what the visitor gets. Excluding 3xx from a comparison as “not a

read” let two addresses serving a parking stub and two answering a 308 score as AGREEMENT. Reads, hard client-visible failures (TLS/refused) and redirects are all comparable; only a transient 000 is coverage loss, and it must stay excluded so packet loss cannot manufacture a finding.

  1. Keep the defective artifact. The broken sweep’s JSON and log are retained under a

DEFECTIVE name beside the corrected ones. A record that hides its own revision is worth less than one that shows it.


Other skills: ai-search-visibility · boil-the-ocean · client-access-checklist · client-relationship-cadence · content-agent · content-factory

The full run order is on the skill-pack directory. Every skill here is one task from the Task Library — the library is the catalogue of what can be done; a pack is the subset you install; an agent is who runs it.

Where this sits in the system

Context is what the worker knows. A skill is the method it follows. A pack is a folder of methods. None of them does any work alone. Work happens when a job runs the right skill against the right context on a schedule, checks its output, and leaves a receipt the next worker can inspect.

  1. Context — the verified facts, goals, evidence, decisions, and working state kept in user-owned Markdown, Obsidian, Drive, or Git so any authorized model can pick up the same work.
  2. Skill — one task, written down to a standard, so an agent can run it without you in the room. There are 239 of them.
  3. PackYOU ARE HERE — those skills bundled into a download you install in one paste.
  4. Agent — a named role with a job description — not a chat window you retype every morning.
  5. Job — a schedule, a QA cycle, and somewhere to keep working files. Miss any of the three and nothing runs twice.
  6. Proof — every finished run written up in public, and the lesson pushed back into the skill.

The map: The System · every asset: Asset Tracker · next door: Asset Tracker.

Scroll to Top