Files
ai-job-search/SECURITY.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

6.7 KiB

Security Policy

Reporting a vulnerability

Please report security findings privately via GitHub private vulnerability reporting rather than a public issue. You will get a response within a few days, credit in the fix unless you prefer otherwise, and public disclosure coordinated with the patch.

If the private form is unavailable, open a public issue that describes the class of problem without a working recipe, and note that you have details to share privately.

Threat model, honestly stated

This is an agentic workflow: an LLM with file access reads untrusted web content (job postings) alongside your personal data (CV, profile, application history). That combination is the main risk surface, and it cannot be fully eliminated - only narrowed. A posting can carry hidden text written to steer the agent. The framework answers that on two levels: instruction-level rules that tell the agent how to treat postings, and permission-level rules that bound what the agent can do even if it is steered.

Instruction level

/apply, /rank, /scrape, /interview and the skill's own 09-web-research.md treat posting text as data, never instructions: agents are told not to follow directions embedded in postings and not to fetch URLs found inside posting text (the user-supplied posting URL is the one exception). Reviewer research starts from the company identity the user confirmed, never from links in the posting body. /apply Step 1 also classifies the posting's host (installed portal, known ATS apex, or unverified) before any drafting.

Permission level

Every grant the template ships, where it lives, how long it lasts, and what checks it. "Guard" is tools/security_guards.py, which CI runs on every PR and which fails on any entry it does not know.

Surface What is granted Lifetime Guard
.claude/settings.json permissions.allow Each shipped portal CLI by exact path, salary_lookup.py, the tools/ scripts (rank_state, job_key, verify_pdf, verify_layout, robots_check, fetch_page), pdftotext, and Skill(job-application-assistant) Whole session Exact allowlist; any addition fails CI unless added to ALLOWED_PERMISSIONS in the same PR
.claude/settings.json permissions.ask Always prompt, even over an allow rule or a skill grant: edits to settings.json, settings.local.json, any .claude/skills/*/SKILL.md, any .claude/commands/*.md, .claude/agents/**, .github/**, tools/security_guards.py; and git push Whole session Each rule is required; removal fails CI
.claude/settings.json permissions.deny Blocked in every mode, bypassPermissions included: curl/wget with data, form or upload flags, scp, sftp, ssh, nc; reads of .env, .env.*, ~/.ssh/**, ~/.aws/**, ~/.gnupg/** (Read denies also cover Bash cat/head/tail/sed) Whole session Each rule is required; removal fails CI
Skill allowed-tools (.claude/skills/*/SKILL.md) job-application-assistant: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion. scrape: the same plus Agent, bun --version, tools/job_key.py. upskill: Read, Write, Glob, Grep, WebFetch, WebSearch. No bare Bash anywhere. The turn that invokes the skill; clears on your next message. Not gated by workspace trust. Exact allowlist (ALLOWED_SKILL_TOOLS); bare tools and wildcards fail CI
Portal skill allowed-tools (.agents/skills/<name>/SKILL.md) Exactly that portal's own cli/src/cli.ts and nothing else Invoking turn Checked by path, so a skill that grants another portal's CLI, or a glob, fails CI
Slash commands (.claude/commands/*.md) No allowed-tools frontmatter; they run under the session's rules above - Same frontmatter check as skills
Hooks (.claude/settings.json hooks) None. The template ships no hooks: a hook runs with no prompt the moment its event fires, which is the vector the Shai-Hulud worm used in August 2026. - Any hook fails CI
Compiling (lualatex, xelatex, a custom template's command) Not pre-approved: a lualatex:* grant would also approve -shell-escape. In default mode each compile prompts. Answering "don't ask again" would persist exactly that lualatex rule to your settings.local.json, so answer per call. - -
Portal CLI manifests (.agents/**/package.json) No lifecycle scripts (preinstall, postinstall, ...) and no trustedDependencies, so bun install runs no code - Fails CI; tools/install_portal_clis.py also skips a manifest that carries one
The browser-header retry (tools/fetch_page.py) A GET with a fixed argv, http(s) only, 45 s / 5 redirects / 10 MB, robots.txt enforced with no bypass flag, output refused inside the repo - Pinned by tests/test_fetch_page.py; the grant names the script, not curl

What this does not do

  • It is not a sandbox. Claude Code's Bash sandbox does not run on native Windows at all, and on macOS and Linux it is off by default. If you are on macOS or Linux, turning it on in your user settings is the single strongest addition you can make; nothing in this repo conflicts with it.
  • Prefix rules are tripwires, not walls. Bash(curl -d:*) matches a command that starts that way; curl -sS -d does not match it. The deny list catches the obvious shapes and makes the rest need deliberate evasion, which is a different bar from preventing it.
  • WebFetch is unrestricted by domain. Company research needs it, and a GET to an arbitrary host can carry data in the URL. The instruction-level rule (never fetch URLs from the posting body) is what stands between a hidden link and a fetch; the permission layer does not.
  • Subagents run with the parent's tools. The /apply reviewer and the /rank scoring agents read posting text with the same tool set as the main session. Narrowing them to read-and-fetch-only agent definitions is planned work, not shipped.
  • Edit and Write are what the skill does. Tailoring a CV means editing files, so those tools stay granted. What the ask list guarantees is that an agent cannot silently turn that into a wider grant: the files that define permissions always prompt.

Instruction-level defenses raise the bar; permission-level defenses bound the damage. Neither is a guarantee. If you run this workflow against job boards you do not trust at all, review what the agent fetched and wrote before sending anything out.

Scope notes

  • Portal CLI skills make live requests only when you run them; CI never does.
  • Community fork skills listed in the forks index are not covered by this policy - review the code you copy, as the index itself says.