* docs(add-portal): specify credential handling for portals needing an API token
/add-portal can already scaffold a skill for a portal that returns usable
content only through a paid fetching service, but the spec says nothing
about the credential such a skill needs: no guidance on where it lives, no
requirement to document it, no defined failure mode when it is unset, and
no point at which the user learns the portal costs money per query.
Three additions, all to the generator spec:
- Step 2 gains a reconnaissance point for whether the portal needs a
credential at all, to be raised with the user before scaffolding. A
portal that bills per query is a different proposition from a free one
and the user should get to decline it.
- The portal-skill contract gains a Credentials rule: environment variable
only, named <SERVICE>_API_TOKEN, never a CLI flag (flags leak into shell
history and process listings), never in url-reference.md or a fixture,
and a MISSING_CREDENTIALS exit rather than a fallthrough to an
unauthenticated request that fails confusingly.
- SKILL.md for such a skill must carry a Setup section naming the service,
the variable, and the per-call billing.
Spec only. All six shipped portals are free and unauthenticated, so no
existing skill changes and the zero-dependency default is untouched.
Evidence this is not speculative: running /add-portal against portals that
sit behind bot-detection produced four token-requiring skills in my own
fork (Bright Data Web Unlocker), each of which had to invent its own
convention for reading, documenting, and failing on the token, because the
spec defines none.
Verified: lint_skills, check_framework_version, security_guards all OK;
python3 -m unittest discover -s tests 196 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(add-portal): subordinate the credential check to the robots.txt decision
Review catch: as written, Step 2.5 could be read as "robots.txt or the
terms said no, but a paid unlocker gets through, so here is the convention
for that" - which would let a paid fetcher launder a robots refusal.
That is not the intent and it contradicts the posture settled in #277/#286
(09-web-research.md, tools/robots_check.py): robots.txt is decided first
and honestly, and is never overridden by better fetching machinery.
State the subordination explicitly in 2.5 rather than leaving it implied by
step order, and record it in the changelog entry so the constraint survives
later edits to the step. The credential path is for portals whose robots.txt
permits access but whose bot protection blocks ordinary fetches - paying for
access a site allows, never paying past a site's no.
Wording follows the reviewer's suggestion.
Verified: lint_skills, check_framework_version, security_guards all OK;
python3 -m unittest discover -s tests 219 passed.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
check_permissions() read permissions.allow and nothing else, so a `hooks`
block in the same file passed the guard silently.
A hook is strictly more dangerous than a pre-approved permission. A
permission pre-approves something Claude may choose to do; a hook runs
unconditionally when its event fires, with no prompt and no model decision
in between. Cloning the repo and opening it is enough.
This is the vector the Shai-Hulud worm used in its August 2026 wave: a
SessionStart hook in .claude/settings.json chaining to .claude/math_init.js,
executing on session start.
https://research.jfrog.com/post/shai-hulud-is-back-august/
For a template that thousands of people are explicitly invited to fork,
that is the riskiest key in the file this guard already parses.
Follows the established pattern exactly - ALLOWED_HOOKS ships empty, since
the template has no hooks, so any addition must be allowlisted in the same
PR and is therefore explicit and reviewable.
Two details worth reviewing closely:
- The hook check runs *before* the permissions shape guards. Those guards
return early, so a file pairing a malformed permissions block with a live
hook would otherwise skip the hook check entirely - a fail-open. Pinned by
test_hook_is_caught_even_when_permissions_block_is_malformed.
- _hook_commands() fails closed. Any hook layout it does not recognise
yields a marker that cannot be in the allowlist, so an unfamiliar shape is
rejected rather than silently skipped, rather than trusting that the
Claude Code schema will not change.
Verified:
- 8 new HookGuardTests cases; 14 of the suite's 26 tests fail against the
unpatched guard, all 26 pass with it
- injecting the real worm shape into this repo's own settings.json makes
the guard exit 1 naming 'SessionStart:node .claude/math_init.js';
removing it returns OK
- lint_skills, check_framework_version, security_guards all OK;
python3 -m unittest discover -s tests 219 passed
/add-portal can generate a skill for a portal that only returns usable
content through a paid fetching service, and such a skill reads its API
token from the environment. Nothing stopped the `.env` holding that token
from being committed: `.gitignore` had no `.env` rule, and
`REQUIRED_IGNORE_RULES` in tools/security_guards.py did not pin one.
No shipped portal needs a credential - all six are free and
unauthenticated - so upstream has never hit this. A fork whose generated
portals do need one hits it on the first `git add -A`.
Add `.env` and `.env.*` to `.gitignore`, and pin both in
`REQUIRED_IGNORE_RULES` so the guard fails if the rule is later dropped.
No negation rule is added, so `ALLOWED_IGNORE_NEGATIONS` is untouched.
Verified:
- `printf 'X=y' > .env && git check-ignore -v .env` -> matched
- dropping the `.env` line makes `python3 tools/security_guards.py`
report the missing rule and fail; restoring it returns OK
- `lint_skills`, `check_framework_version`, `security_guards` all OK;
`python3 -m unittest discover -s tests` 196 passed
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>