Files
ai-job-search/.claude/commands/add-portal.md
T
Mads LorentzenandClaude Fable 5.1 01a3c0e5f6 security: narrow skill grants, ship ask/deny rules, guard skill frontmatter, GET-only 403 retry (#642)
* security(skills): drop bare Bash and the portal CLI glob from skill grants

A skill's allowed-tools pre-approves the listed tools for the turn that
invokes it, even in an untrusted folder. job-application-assistant granted
bare Bash, Edit and Write; job-scraper granted bun run on any
.agents/skills/*/cli/src/cli.ts. Every Bash command the skills document is
already allowlisted per command in .claude/settings.json, so the bare and
glob grants bought nothing but exposure. The per-portal grants stay in
settings.json, which is where a portal added by /add-portal registers too
(Step 5 now says so).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* security(settings): deny exfil-shaped shell and credential reads, always ask on permission-defining files

Deny rules block in every permission mode including bypassPermissions and
override any allow rule or skill grant; ask rules force a prompt even when
a skill's allowed-tools would pre-approve the call. settings.json now
ships both: deny for shell that uploads or opens a remote shell (curl
data/form/upload flags, wget post, scp, sftp, ssh, nc) and for reads of
.env and ~/.ssh, ~/.aws, ~/.gnupg; ask for the files whose content is the
permission surface (settings, skill frontmatter, agents, CI, this guard)
and for git push. security_guards.py requires each rule to stay present,
the same way it requires the personal-data gitignore rules. Prefix rules
are tripwires, not walls; SECURITY.md will say so.

Also allowlists tools/robots_check.py and the upcoming tools/fetch_page.py
so the 403 retry no longer needs a curl grant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* security(guards): hold skill and command allowed-tools to a reviewed allowlist

The guard allowlisted settings.json permissions but never read skill
frontmatter, so the narrow settings allowlist sat next to a bare Bash
grant it could not see. Check 4 parses every .claude/skills/*/SKILL.md,
.agents/skills/*/SKILL.md and .claude/commands/*.md frontmatter with a
stdlib parser (scalar, folded, and list forms) and holds each
allowed-tools entry to ALLOWED_SKILL_TOOLS; a portal skill may
additionally grant exactly its own cli/src/cli.ts, matched by path so no
portal name is hardcoded. Bare Bash and mid-pattern wildcards get their
own message. On master's frontmatter this check reports two failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(tools): add GET-only fetch_page.py for the browser-header retry

09-web-research.md documented the 403 retry as a curl command with browser
headers plus an inline python3 -c tag-stripper, which meant the workflow
wanted a Bash(curl:*) grant - and curl with a free tail is an upload
channel. fetch_page.py is the same retry as one command with a fixed GET
argv (URL after --, bounded to 45 s / 5 redirects / 10 MB), http(s) only,
robots_check.gate() enforced with no bypass flag, --out refused inside
the repo, and --text for the stripped page. urllib fallback when curl is
absent. The settings.json grant names the script.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(security): describe the permission surface as a table; route the 403 retry through fetch_page.py

SECURITY.md now lists every shipped grant with its location, lifetime
and guard, and says plainly what the permission layer does not do: no
sandbox on native Windows, prefix rules are tripwires, WebFetch is
unrestricted by domain, subagents run with the parent's tools (next
change), and Edit/Write stay granted because tailoring a CV is editing
files. 09-web-research.md's retry section is the one fetch_page.py
command (framework_version 1.1.3 -> 1.1.4); apply, rank and scrape point
at it instead of curl. SETUP.md and the README tools tree follow.
CHANGELOG gains a Security section under [Unreleased].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* security(review): ask on command frontmatter, portal skills grant only their CLI, bound the urllib fallback

Findings from a fresh-context review of the branch:

- A command's frontmatter is a grant too (check 4 already parses it), so
  Edit(.claude/commands/*.md) joins the required ask rules; without it a
  steered session could write `allowed-tools: Bash` into apply.md with no
  prompt and the next /apply would be pre-approved for bare Bash.
- SECURITY.md promised a portal skill may grant "its own CLI and nothing
  else"; the guard now enforces exactly that instead of own-CLI plus the
  core allowlist. All six shipped portal skills already comply.
- fetch_page.py's urllib fallback now matches curl's 5-redirect cap and
  applies the 10 MB cap after decompression, so the docstring's bounds
  hold on both paths.
- Frontmatter parser: a BOM no longer reads as "missing frontmatter",
  YAML flow lists split correctly, and a non-UTF-8 file fails with a
  message instead of a traceback. Module docstring lists check 4.
- CHANGELOG no longer claims "no new prompt": compile and aux cleanup,
  which the removed bare Bash covered in a skill-invoking turn, now
  prompt, deliberately. "Once per session" wording corrected to per call.
- Three upload-shaped deny siblings added (curl -XPOST, curl --request
  POST, wget --method=POST); /add-portal recon points at fetch_page.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 17:15:25 +02:00

14 KiB

/add-portal - Generate a Job-Portal Search Skill for Your Local Market

You are helping the user build a job-portal search skill for a job board in their market. The repo ships worked examples of the pattern (four Danish portals plus the country-agnostic linkedin-search and freehire-search), and the README invites users elsewhere to build equivalents — this command turns that invitation into a guided workflow: investigate the portal, scaffold the skill from the canonical structure, and test-run a live query before registering anything.

The generator is country-agnostic: it works for any portal in any market and language. The skills it produces are typically market-specific and live in the user's fork (per repo policy, country-specific portal skills are not merged upstream — the generator is the upstream feature, its output is yours).

$ARGUMENTS may contain a subcommand, a portal URL, or nothing.

Follow these steps in order.


Step 0: Parse Arguments

  • If $ARGUMENTS contains --list: use Glob with .agents/skills/*/SKILL.md, print a table of installed portal skills (name, market from the description, data source from url-reference.md), and stop.
  • If $ARGUMENTS contains a URL: treat it as the portal URL and carry it into Step 1.
  • Otherwise: start the interview at Step 1.

Step 1: Interview - Portal Basics

Ask the user (skip anything already answered by $ARGUMENTS):

  1. Portal URL - the job board's public site (e.g. https://www.seek.com.au, https://www.stepstone.de).
  2. Skill name - kebab-case, suffixed -search (e.g. seek-search, stepstone-search). Must not collide with an existing folder in .agents/skills/.
  3. Market and language - which country/region the portal covers and what language its postings use. This drives the trigger phrases in SKILL.md (include local-language terms like the Danish skills do: "ledige stillinger", "jobsøgning").
  4. A realistic test query - a job title or skill the user would actually search for, used for the live test in Step 4.

Step 2: Investigate the Portal

Do reconnaissance before writing any code. Use WebFetch (or python3 tools/fetch_page.py '<URL>' --text via Bash when WebFetch is refused) on the portal:

  1. Find the search URL pattern. Load the portal's search page, run a search in the URL bar mentally or via fetch, and identify: the search endpoint, the query parameter, and any parameters for location, posting age, and pagination. Prefer a JSON API if one backs the site (check for /api/ XHR endpoints in the page source); otherwise plan to parse the HTML results page.

  2. Fetch one search-results response for the test query and identify the per-result fields: id, title, company, location, posting date, and URL. For HTML, note the class names / attributes that anchor each field. For JSON, note the field paths.

  3. Find the detail-page pattern - the URL that returns a single posting's full description, and where the description, deadline, employment type, and apply link live in it.

  4. Check access requirements and terms.

    • Fetch robots.txt and check whether the search/detail paths are disallowed.
    • If the portal requires login/authentication to view listings, stop: this pattern only works on public pages. Tell the user and suggest checking whether the portal has an official API.
    • If robots.txt disallows the paths or the portal's terms prohibit automated access, tell the user plainly and let them decide whether to proceed for personal use. If they proceed, the generated SKILL.md must carry a prominent personal-use-only warning (copy the tone of linkedin-search's "⚠️ Personal use only" section: keep volume low, no commercial or bulk use, own responsibility).
  5. Check whether the portal can be reached without a credential. Some portals return usable content only through a third-party fetching service (a paid unlocker/proxy API). This step never overrides Step 2.4: if robots.txt or the portal's terms disallow access, that is decided there, and a paid fetching service does not change the answer. The credential path exists for portals whose robots.txt permits access but whose bot protection blocks ordinary fetches. Where that applies and the test fetch succeeds only through such a service, say so to the user before scaffolding - a portal that bills per query is a different proposition from a free one, and they may prefer to skip it. Note which service and which environment variable; the handling rules are in the portal-skill contract in Step 3.

Record everything you found - endpoints, parameters, field anchors, quirks - you will write it into url-reference.md in Step 3.


Step 3: Scaffold the Skill

Canonical reference: read .agents/skills/linkedin-search/ before generating - it is the zero-dependency worked example of this exact structure. Copy its architecture, not its LinkedIn-specific parsing.

Create .agents/skills/<name>/ with:

<name>/
├── SKILL.md              # Skill definition with trigger phrases
├── url-reference.md      # Endpoint documentation from Step 2
└── cli/
    ├── package.json
    ├── tsconfig.json
    ├── README.md
    ├── src/
    │   ├── cli.ts        # Arg parsing, help text, command dispatch
    │   ├── helpers.ts    # Fetch with backoff, parsers, error writer
    │   └── commands/
    │       ├── search.ts
    │       └── detail.ts
    └── tests/
        └── helpers.ts    # runCLI + parseJSON test utilities (copy from jobindex-search)

The portal-skill contract (every generated skill MUST honor this)

These conventions are what make portal skills interchangeable for /scrape and for users reading any skill's docs:

  • Commands: search and detail <id|url>.
  • Search flags: --query/-q, --jobage <days> (posting age; map to the portal's parameter, note in SKILL.md if unsupported), --page <n> (1-indexed), --limit <n> (client-side cap), --format json|table|plain (default json). Add --location/-l if the portal supports location as a parameter; if it only supports location inside the keyword query, document that in SKILL.md the way jobindex-search does ("include the city in --query").
  • JSON output shape: { "meta": { "count": ..., "page": ... }, "results": [...] } where each result has at least id, title, company, location, date, url (missing values are null, never omitted).
  • Errors: written to stderr as { "error": "...", "code": "..." }, exit code 1. Never write errors to stdout.
  • Fetching: an honest User-Agent that names the tool (Mozilla/5.0 (compatible; <portal>-cli/1.0), the convention every shipped portal CLI follows) - never a full browser impersonation; if the portal refuses that UA, escalation to browser headers goes through the robots.txt gate in .claude/skills/job-application-assistant/09-web-research.md, not through the CLI's default. Exponential backoff with jitter on 429/5xx (max ~6 retries), ""/null on 404 rather than a crash.
  • HTML parsing: split the response into per-result chunks and parse each independently, so one malformed card cannot break the rest (see parseJobCards in linkedin-search/cli/src/helpers.ts).
  • Dependencies: default to zero runtime dependencies (plain bun + fetch + regex parsing) like linkedin-search - bun install should only pull dev types. Only add a parsing library if the portal's markup genuinely defeats chunked regex parsing, and say so in the README.
  • Credentials: a skill that needs an API key (Step 2.5) reads it only from an environment variable named <SERVICE>_API_TOKEN. Never hardcode it, never accept it as a CLI flag (flags leak into shell history and process listings), and never write a real token into url-reference.md, a README example, or a test fixture. If the variable is unset, exit 1 with the standard stderr JSON error and code MISSING_CREDENTIALS, naming the variable to set - never fall through to an unauthenticated request that fails confusingly. The repo .gitignore covers .env; do not commit one.

File specifics

  • SKILL.md frontmatter: name, version: 1.0.0, a description written for skill triggering - it must name the portal, the market, and include trigger phrases in English and the market's language; context: fork; allowed-tools: Bash(bun run .agents/skills/<name>/cli/src/cli.ts *).
  • SKILL.md body: what the skill searches, the personal-use warning if Step 2 found terms restrictions, command reference with flags, 4-6 usage examples using the user's market (real cities, realistic roles), output-format table, and a Notes section recording portal quirks found in Step 2. If Step 2.5 found the portal needs a credential, add a Setup section naming the service, the exact environment variable to export, and the fact that every call is billed - stated where the user reads it before running the skill, not after.
  • url-reference.md: the endpoints, parameters table, and response-structure notes from Step 2 - this is the file a future maintainer needs when the portal changes its markup.
  • package.json: name <portal>-cli, "type": "module", scripts start, test (bun test --timeout 30000), and typecheck (tsc --noEmit); dev-only dependencies in the zero-dependency default.
  • tests/: copy runCLI/parseJSON from jobindex-search/cli/tests/helpers.ts, then add a small live smoke-test file: search with the test query returns exit code 0 and ≥1 result with non-null id/title/url; a bogus flag or missing required arg exits 1 with a JSON error on stderr.

Step 4: Test-Run a Live Query (MANDATORY)

Never register a portal skill that has not returned real results. Markup assumptions from Step 2 routinely miss quirks that only show up live.

  1. Install dev types and typecheck:
    cd .agents/skills/<name>/cli && bun install && bun run typecheck
    
  2. Run the live search with the user's test query:
    bun run src/cli.ts search -q "<test query>" --limit 5 --format table
    
  3. Verify the results are real and complete: titles and companies are populated (not empty strings or HTML fragments), URLs resolve to the portal, dates parse. If fields come back null or garbled, fix the parsers in helpers.ts and re-run. Iterate until clean.
  4. Take one id from the results and run detail:
    bun run src/cli.ts detail <id> --format plain
    
    Verify the description is readable text (entities decoded, tags stripped, paragraph breaks preserved).
  5. Run the test suite: bun run test.
  6. Keep volume low during iteration - a handful of requests, not a crawl. If the portal rate-limits you mid-test, back off and tell the user.

Do not proceed to Step 5 until search, detail, and tests all pass.


Step 5: Register

  1. Ask whether the user wants the new portal added to their /scrape search strategy. If yes:
    • The portal CLI itself is already picked up automatically by /scrape (it discovers .agents/skills/*/SKILL.md) — no further wiring is needed for CLI search/detail.
    • Pre-approval is per portal and deliberate. /scrape runs the CLI through Bash, and the only standing grants are the per-portal entries in .claude/settings.json. Add Bash(bun run .agents/skills/<name>/cli/src/cli.ts:*) to permissions.allow there and to ALLOWED_PERMISSIONS in tools/security_guards.py in the same commit - the guard fails the fork's CI on a settings entry it does not know, which is the review step working as intended. Until both are in place, /scrape prompts on each call of this portal's CLI; the generated SKILL.md's own allowed-tools line covers only turns that invoke that skill directly.
    • Optionally add WebSearch/site: placeholder queries for that board in .claude/skills/job-scraper/search-queries.md (use the [YOUR_JOB_BOARD] style placeholders already there) so the fallback path still covers the board if the CLI is unavailable.
  2. Remind the user to add the install line for their own records if they maintain a fork README:
    cd .agents/skills/<name>/cli && bun install && cd ../../../..
    
    (Skip if the skill is zero-dependency and they don't care about typecheck types.)
  3. Note that the skill auto-triggers from its SKILL.md description - no other wiring is needed.
  4. CI coverage is also automatic: the cli-checks job discovers every .agents/skills/*/cli/package.json, so the new CLI's typecheck and test scripts run on every push to the fork without editing the workflow.

Step 6: Confirm

Present a summary:

Portal skill <name> generated and verified.

  • Files: .agents/skills/<name>/ (SKILL.md, url-reference.md, CLI with tests)
  • Live test: search "<test query>" returned results; detail verified on one posting
  • Data source: ; <personal-use warning noted, if applicable>

Try it: bun run .agents/skills/<name>/cli/src/cli.ts search -q "<test query>" --format table

Per upstream policy, market-specific skills like this live in your fork rather than being PR'd upstream. If the portal changes its markup later, url-reference.md records the parsing anchors to update.


Design Principles

  • The generator is country-agnostic; its output is market-specific and stays in the user's fork. This matches the repo policy that upstream stays a universal template.
  • Investigation before scaffolding: the command never generates parsers from guesses - Step 2 fetches real responses first, and Step 4 verifies against live data before anything is registered.
  • The portal-skill contract keeps every generated skill interchangeable with the shipped ones: same commands, same flags, same output shape, same error convention.
  • Zero runtime dependencies by default, matching linkedin-search - a portal skill should run on a fresh clone with nothing but bun.
  • Access rules are surfaced, not silently bypassed: auth-walled portals are declined, robots.txt/ToS restrictions are reported to the user, and restricted portals get a prominent personal-use-only warning in the generated skill.
  • Credentials live in the environment, never in the repo: a generated skill reads its token from an environment variable, fails loudly when it is unset, and never commits it. Per-call cost is disclosed before the skill is generated, not discovered afterwards.