Commit Graph
36 Commits
Author SHA1 Message Date
Muhammad Haseeb 3efc52ebd5 feat(security): hold .claude/settings.json hooks to a reviewed allowlist (#313)
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
2026-08-10 21:18:33 +02:00
Muhammad HaseebandClaude Opus 5 a7ac6fea75 fix(security): ignore .env so a generated portal skill's API token can't be committed (#303)
/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>
2026-08-07 15:49:40 +02:00
8ffe987f09 fix(robots): the gate did not fail closed on a soft 200 or an encoded Disallow (#286)
Found by an adversarial review run over the merged checker, prompted to falsify
rather than confirm. Both are pinned in tests/test_robots_check.py.

A soft 200 granted permission. A host answering /robots.txt with an HTML error
page at status 200 produces a body that parses to zero rules, and zero rules
read as "allowed" - so the browser-header retry ran on permission that was
never given:

    rc._fetch = lambda url, ua: ("<html>404 Not Found</html>", 200)
    rc.gate("https://x.example/jobs")
    # -> (0, 'ALLOWED - robots.txt permits this path')

A non-empty body carrying no recognised directive is now treated as unreadable.
A genuinely empty file stays allow-all per RFC 9309, so this does not
over-correct.

Disallow patterns were never percent-decoded while the request path was, so
"Disallow: /foo%20bar" never matched "/foo bar" and the rule was silently
skipped.

Also adds the "--" terminator before the URL in the curl argv, plus an explicit
--max-redirs 5. gate() rebuilds the target as scheme://host/robots.txt before
calling _fetch, so the gate path was never exposed to a dash-leading URL - this
is hardening for direct callers. Three tests pin it: the terminator is present,
a dash-leading argument fails closed end to end, and gate() never passes a
caller-supplied URL through to curl.

187 tests pass.


Claude-Session: https://claude.ai/code/session_01XTtiXab1yUFF2aL4s3fVY1

Co-authored-by: kgb <kevingblackman@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 07:59:34 +02:00
Mads LorentzenandClaude Fable 5 60c735946d fix(upstream-checker): track 09-web-research.md in FRAMEWORK_FILES
The file shipped in #277 but was never added to the manifest, so forks
got no signal when it changed. Surfaced during #282 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 06:34:00 +02:00
Oscar Madera ce60b08e81 fix(upstream-checker): report files missing from the upstream ref instead of silent OK (#282)
The per-file 'git show' failure was swallowed with a bare continue, so a file renamed or deleted upstream (or any unexpected git error) ended with a clean '[OK] All framework files are up to date' - a false all-clear.

Now the two failure modes are distinguished: files present locally but missing from the upstream ref are listed explicitly with a final [WARNING] instead of [OK], and unexpected git errors are added to the configuration errors with their stderr.

Adds UpstreamRefMissingFileTests, which simulates upstream dropping AGENTS.md while the fork keeps its copy: it fails on master and passes with the fix.
2026-08-05 06:28:23 +02:00
fcefb8150f fix(web-research): stop treating a WebFetch 403 as a dead posting (#277)
* fix(web-research): stop treating a WebFetch 403 as a dead posting

WebFetch sends a bot user agent, and many bank and corporate sites answer
with HTTP 403 while serving the same page to a browser normally. Every
command treated that as "page unavailable" and degraded silently rather
than failing loudly:

- /rank marked live postings `expired`
- /apply fell back to search snippets, or to vague cover-letter prose
- /scrape stored listing-page `#fragment` URLs, which fetch fine and
  return unrelated jobs, so every later /rank and /apply run on that
  entry failed

Adds 09-web-research.md as the single reference: the trust boundary, a
curl browser-header retry with a tag-stripping extractor, a four-step
escalation order, the login-wall case, why the employer's own careers
posting beats an aggregator listing (the requisition ID and the grade
survive there), and the rule that a search-result snippet is a lead
rather than a source.

Wires it into /apply, /rank, /interview, /outcome, /notion-sync, the
job-scraper skill, and writing-style rule 5. Bumps 03-writing-style.md
to 1.2.0; 09-web-research.md starts at 1.0.0.

Aggregator examples are given generically (LinkedIn, Indeed, national
job boards) so the guidance holds in any market.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web-research): gate the browser-header retry on robots.txt

Addresses review feedback on #277.

WebFetch identifies itself as Claude-User and honors robots.txt, so a 403 has
two very different causes and they must not be treated the same: a WAF default
on a site whose published policy allows access, or a site that has actually
declined. Retrying with browser headers in the second case circumvents the very
opt-out mechanism site owners are told they can rely on, and the core framework
cannot hold a looser standard than it asks of community forks.

The escalation now runs tools/robots_check.py before the retry. A disallow for
"*" or for "Claude-User" skips the retry entirely and goes to step 3 (find the
employer's own posting). The rule is stated plainly in 09-web-research.md so
later edits do not erode it: the retry exists to get past bot-filtering
firewalls on sites whose robots.txt permits access; it is never used to
override a site that has said no.

Two findings from testing the gate against live sites, both pinned by
tests/test_robots_check.py (15 offline cases):

- The WAF usually blocks robots.txt too. privatebank.barclays.com returns 403
  on the policy file to Claude-User and 200 to a browser, so a naive gate would
  block the retry on exactly the sites the retry is for. The checker reads the
  policy as a browser when the honest request is refused, then obeys it
  strictly - a policy you are prevented from reading cannot be honored, and
  robots.txt is not the protected resource.
- urllib.robotparser cannot be used. It ends a record at a blank line and
  matches rules in file order, so Barclays' real file (blank lines between
  "User-agent: *" and its rules, "Allow: /" before "Disallow: /cs/") reads as
  everything-allowed. That fails open, in the one direction that matters. The
  checker implements RFC 9309 longest-match instead, with ties resolved to
  Disallow rather than Allow.

Verified live: barclays /careers/ allowed and /cs/ blocked, ubs.com allowed,
jobup.ch /api/ blocked while /en/jobs/ stays allowed. 09-web-research.md
1.0.0 to 1.1.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: kgb <kevingblackman@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:36:55 +02:00
Oscar Madera 0433f3e332 fix(check_upstream_updates): compare template repo URL case-insensitively (#273)
GitHub serves repo paths case-insensitively, so a direct clone from https://github.com/madslorentzen/ai-job-search (lowercased) triggered the fork-vs-self warning even though origin is the template repo itself. Lowercase both sides of the check.

New test clones with a lowercased URL: fails on the previous check, passes with this fix.
2026-08-02 21:15:28 +02:00
Ayobami Adegoke 4f7f11ef4e fix(salary): parse localized numeric strings (#272) 2026-08-02 21:15:09 +02:00
Ayobami Adegoke 72f1f3d608 test(security): require personal output ignore rules (#271) 2026-08-01 22:21:20 +02:00
Oscar Madera 72bbe00529 fix(check_upstream_updates): warn when check falls back to a fork's own origin (#265)
On a fork without an 'upstream' remote, the checker silently fell back to 'origin' (the fork itself) and still printed '[OK] All framework files are up to date with upstream!', a false positive: the fork is always up to date with itself, so upstream updates were never reported. This is exactly the setup CONTRIBUTING.md recommends for forks.

Now, when the fallback remote does not point at the ai-job-search template repo, the script warns that the comparison is fork-vs-self and prints the command to add the template as a remote. The final OK line now names the ref it actually compared against.

Tests (new tests/test_check_upstream_updates.py, three scenarios) fail on master and pass with the fix.
2026-08-01 22:00:49 +02:00
Mads LorentzenandClaude Fable 5 2c41210019 fix(security-guards): sync gitignore guard with the Cover_*.* and cv/*.txt rules
Two personal-data ignore rules existed in .gitignore but not in
REQUIRED_IGNORE_RULES, so a change weakening either would have passed CI:
cover_letters/Cover_*.* (the uppercase naming variant /apply recognizes)
and cv/*.txt (ATS text extractions of tailored CVs).

Also: regression tests pinning #252's ragged-row bounds fix in
convert_salary_excel.py (mutation-verified), and removal of the vestigial
cover_letters/OpenFonts/cover.cls, which since #252's rename ambiguously
declared the same class as the real cover.cls (zero references; cover
letter re-compiled and page-verified after removal).

Guard-list gap surfaced by CodeRabbit's review on jakob1379's Nix demo
fork PR (jakob1379/ai-job-search#1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:55:09 +02:00
Adwait M. 5a9f6c42a4 fix: remove duplicate LaTeX imports, add row bounds safety, improve error messaging (#252) 2026-07-29 19:20:40 +02:00
Novica Nakov 82a60300b6 feat(add-template): make /add-template engine-agnostic (#238)
* feat(add-template): make /add-template engine-agnostic so Typst can register alongside LaTeX

/add-template hardcoded a lualatex|xelatex|pdflatex engine enum and .tex
assumptions, so custom templates could only be LaTeX. Replace the enum with a
declared source extension + compile command, so any toolchain (Typst via
`typst compile`, or others) registers the same way stock LaTeX templates did.

Stock CV/cover-letter pipeline stays LaTeX and untouched (per #181).

Also fixes a latent bug this surfaced: apply.md's compile step ignored the
ACTIVE-TEMPLATE block and always ran lualatex/xelatex on .tex regardless of
the active template, and .gitignore's cv/main_*.tex pattern would not have
ignored a non-.tex draft (personal-data leak). Both now resolve from the
declared extension/command.

* fix(add-template): satisfy security_guards on the .gitignore Typst fix

security_guards.py pins the personal-data ignore rules by exact string and
gates negations through an allowlist, so broadening cv/main_*.tex and
cover_letters/cover_*.tex to *.* (for .typ drafts) needed a matching update
to REQUIRED_IGNORE_RULES.

Also tighten the .gitignore itself per review: keep the re-include
negations at .tex instead of widening them to *.* too. The stock example
files are always LaTeX, so .tex is enough to re-include them, and a
wildcard negation would have also re-included build artifacts
(main_example.pdf/.aux) that should stay ignored. ALLOWED_IGNORE_NEGATIONS
needs no change since the negations are unchanged.

Also adds a CHANGELOG entry under Unreleased for the Typst/custom-template
support.
2026-07-26 16:21:33 +02:00
LeoWinston-9596andClaude Opus 4.8 41ddc0c73c RFC feat(08): application-form fields as a third /apply artifact (#212)
* feat(08): add application-form fields as a third /apply artifact

/apply produces a CV and a cover letter. Many applications need a third
thing: free-text typed into a portal. Graduate programs, large-employer
ATS systems and startup forms ask for self-introductions, structured
project entries, motivation questions and pitches under a hard character
limit - none of which either document covers, and all of which the
interviewer reads alongside the CV.

Governing rule: a form field selects from what is already true and
arranges it for the question asked. It never introduces a new claim.
All accuracy rules from 03 and 05 apply unchanged.

Covers three field types (self-introduction, structured project entries,
hard character limits), the output format (a plain .txt the candidate
pastes from, with counts stated and internal NOTE TO SELF blocks marked
as not-for-pasting), and a verification checklist.

Two places where form fields are stricter than a CV, because both are
easy to get wrong:

- Project entries carry a name and a role, so they read as ownership of
  the whole project in a way a terse CV bullet does not. Contributory
  work has to be scoped inside the description.
- Project dates are the dates of the project, not of the employment.
  Narrowing them is more accurate where the candidate can say when the
  project started - but never invent a boundary to improve the ratio.

Registers the file in SKILL.md (framework_version 1.0.1 -> 1.1.0) and in
the FRAMEWORK_FILES list in tools/check_upstream_updates.py, so it is
covered by the update check like every other framework file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fixup(08,apply): wire as optional /apply offer; align grounding to three-source union

- apply.md Step 6: offer the third artifact after CV/cover letter are
  produced, mirroring the /outcome house pattern for optional capabilities
  (offer, act only on yes, default output unchanged).
- 08-application-forms.md: ground claims against the framework's
  01-candidate-profile.md + master CV + CLAUDE.md union (per #185)
  instead of only 01, in both the governing rule and the checklist.

Per MadsLorentzen review on PR #212. Rebase onto merged #210 (the
tenure-check reference this file cites) still pending — #210 hasn't
landed on upstream/master yet.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:05:42 +02:00
Johnson K C aa7c707399 fix(convert_salary_excel): store standalone count columns as counts, not indexes (#230)
An unmatched count column (e.g. a lone total headcount with no paired index
column) was appended as an untyped standalone value and stored under "index",
even though detect_column_type had already classified it as a count.
salary_lookup then rendered the raw headcount as a salary index with a
meaningless "vs baseline" percentage.

Tag unmatched count columns with field="count" so the row parser stores them
under "count" (as an int, matching the paired-count branch). Standalone index
and untyped columns are unaffected.
2026-07-23 10:32:22 +02:00
Oscar Madera 3609f584b5 fix(convert_salary_excel): pair count/index columns by category name, not adjacency (#219)
The sequential scan assumed count/index pairs are always adjacent.
Interleaved columns like Count_A, Count_B, Index_A, Index_B produced
wrong pairings (Count_B ↔ Index_A), silently corrupting data.

Now columns are grouped by type, then matched by the category name
derived from stripping type words. Unmatched columns fall back to
standalone value columns using the original header name.
2026-07-22 20:34:48 +02:00
LeoWinston-9596 808be3daad fix(privacy): ignore scraper state and interview records at any depth (#208)
job_scraper/seen_jobs.json (and notion_sync.json / *.md) were ignored by a repo-rooted pattern, but the job-scraper skill resolves job_scraper/ relative to its own directory, so the state file lands at .claude/skills/job-scraper/job_scraper/ and the rule never matched - publishing every scraped posting with fit scores and skip-reasons on a public fork. Switches to **/-prefixed patterns that match at any depth (the rooted location still matches too, so no regression), and adds documents/interview/** (interview prep names employers, quotes submitted material, and lists the candidate's weak points) - it was never ignored though documents/applications/** was. REQUIRED_IGNORE_RULES updated in lockstep so the security guard stays in sync.

By @LeoWinston-9596 (split from #199). Verified: both nested and root seen_jobs.json now ignored, interview records ignored, guard suite green (17 tests incl. #195's negation checks). Rebased cleanly on current master.
2026-07-21 07:39:56 +02:00
Oscar Madera b3b351605c fix(salary): detect city column from header token, not exact match (#201)
convert_salary_excel.py detected the city column via exact membership (h_lower in CITY_PATTERNS), so real headers like "City Name", "City/Kommune", or "Kommune <suffix>" never matched and every company was written with an empty city field. Switches to header_matches(h, CITY_PATTERNS) - the same whole-token matcher already used for the company, count, index, and ID columns. Same bug class as #151 (company column); bare "City"/"Kommune" inputs are unaffected. Regression test covers bare and suffixed headers.

By @oscarbol09.
2026-07-20 20:20:53 +02:00
Thejesh Reddy 36462e356e fix(security_guards): reject un-allowlisted .gitignore negations (#195)
check_gitignore() verified each required personal-data rule was present via set membership, but .gitignore is order-sensitive: a later !pattern re-includes a file an earlier rule excluded, so the required line stays physically present while the file is no longer ignored - the guard failed open on exactly the weakening its docstring claims to catch. Keeps the required-rules-present check and additionally rejects any negation line outside a small reviewed ALLOWED_IGNORE_NEGATIONS allowlist (same explicit-widening pattern as ALLOWED_PERMISSIONS). Fixes #194.

By @thejesh23. Verified: allowlist matches the four negations currently in .gitignore; guard test suite passes locally (17 tests) and in CI.

Closes #194
2026-07-20 18:46:58 +02:00
Jovin Nicholas 1db48568b3 feat(agents-config): add root AGENTS.md thin-pointer specification (#159)
Root AGENTS.md pointer file per the architecture decision in discussion #78: documents both config roots (.agents/skills/ portable portal skills, .claude/ orchestration) and the profile entry points, carries a framework_version marker registered in both version tools.

Design case made by @erikpr1994 in the #78 architecture thread; implementation by @jovin-nicholas.
2026-07-15 22:33:42 +02:00
Alaa-TaiebandTunic Assistant 55ba1c1652 fix(salary): validate category shape and add --validate preflight (#156)
validate_data() accepted category values that are not {count?, index?}
objects. They slipped through to format_entry(), which then raised
AttributeError on a normal table lookup (or serialized a malformed shape
under --json). It also accepted duplicate company names silently.

- collect_validation_issues() now also flags a non-object category value
  (and non-numeric count / non number-or-string index) as a hard error,
  and duplicate company names as a warning.
- validate_data() keeps its eager-fail behavior (same messages), so
  existing tests and load_data() are unchanged.
- --validate runs the checks standalone and prints an actionable report
  (exit 1 on errors, 0 on warnings-only/clean), letting users pre-flight
  their BYO salary_data.json.

Reproduced on master: validate_data({'companies':[{'company':'Acme',
'categories':{'eng':'not_a_dict'}}]}) returns without error, but
format_entry then raises AttributeError.

Co-authored-by: Tunic Assistant <assistant@tunic.local>
2026-07-15 07:53:01 +02:00
Alaa-Taieb 1417e3cbdf fix(salary): skip non-numeric and identifier columns in Excel conversion (#152)
parse_sheet treated every column that was not company/city as a salary category, with no check that the column actually held numeric salary data. This turned free-text columns (e.g. Notes) into bogus string categories and numeric identifier columns (e.g. Id) into mistaken salary indexes.

- Drop identifier headers (ID_PATTERNS = {id, personnummer}) at classification time.

- Skip non-numeric standalone values and fully-null count/index pairs at row-processing time.

- Adds regression tests (skips_free_text_column, skips_numeric_identifier_column, keeps_numeric_salary_column) that fail on master and pass after the fix.
2026-07-14 20:11:11 +02:00
Alaa-Taieb 4128ca0318 fix(salary): detect company column from header token, not exact match (#151)
convert_salary_excel.py detected the company column via exact membership
in COMPANY_PATTERNS, so common real-world headers like "Company Name" or
"Employer Name" were never matched. parse_sheet then returned [] for that
sheet, silently dropping it from salary_data.json (or exiting with no
output for a single-sheet file).

Route company-column detection through the existing header_matches()
token matcher (already used for count/index detection). This only adds
detections; inputs that already worked (bare "Company"/"Firma"/...) are
unaffected.

Adds a regression test in tests/test_convert_salary_excel.py that fails
on master (returns []) and passes after the fix.
2026-07-14 14:35:45 +02:00
Erik Pastor Rios b20467df4a feat(versioning): framework_version markers, CI version guard, and fork update checker (#144)
* Add framework_version markers to assistant skills and implement CI version guard and update checker

* Address review feedback: update ci.yml based on latest upstream, gate CI version guard to upstream repo, and remove non-ASCII characters from check_upstream_updates.py
2026-07-14 14:29:24 +02:00
Ayobami Adegoke a03529f894 fix(lint): report malformed settings shapes without crashing (#146)
Valid JSON such as [] or {"permissions": []} caused lint_skills.py to raise AttributeError because it assumed both values were objects.

Validate the top-level settings value and permissions object before reading nested keys. Malformed settings now produce clear lint errors and exit 1 without a traceback.

Add subprocess regression tests covering invalid JSON, malformed root values, invalid permissions values, and non-list permissions.allow values.
2026-07-13 20:45:22 +02:00
Ayobami Adegoke 160b479868 ci: verify PDF page counts and text layers via poppler (#145) 2026-07-13 16:36:46 +02:00
Ayobami Adegoke 569b1df371 fix(security): harden guard JSON shape handling (#128) 2026-07-11 22:04:54 +02:00
♦ jabarii♦ c134eef553 refactor(salary): optimize search match scoring and normalize Excel category keys (#101)
This commit improves the performance and consistency of the salary tools:

- Redundant query normalization and word extraction are eliminated in salary_lookup.py by pre-calculating representations once before the search loop.
- A match_score_optimized helper is introduced to perform the comparison using the pre-calculated query data, preserving full backward compatibility for match_score.
- Normalization in tools/convert_salary_excel.py is unified: paired column headers now consistently substitute spaces and dashes with underscores (e.g. 'software_engineering') to match the single-column formatting.
- Unit test coverage is significantly expanded in tests/test_salary_lookup.py and tests/test_convert_salary_excel.py to cover normalization, anglicization, search filtering, and matching behaviors.
2026-07-10 15:24:20 +02:00
Erik Pastor RiosandClaude Opus 4.8 a278ad7a50 refactor(salary): make compound-word matching locale-agnostic (#94)
* refactor(salary): make compound-word matching locale-agnostic

The Excel column detector hardcoded a DANISH_COMPOUND_PATTERNS set inside
header_matches(), so the compound-word matching that helps Danish headers
(e.g. "lønindeks") was baked into the algorithm by name and unavailable to
any other locale without editing the source.

Rename it to COMPOUND_PATTERNS and pass it as a parameter (default
unchanged, so the Danish demonstration data behaves identically). A
different-locale spreadsheet can now supply its own compound tokens via
header_matches(..., compound_patterns=...). Add a test covering both the
preserved default and the parameterized path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(salary): drop unused compound_patterns parameter

Per review: keep the DANISH_COMPOUND_PATTERNS -> COMPOUND_PATTERNS
rename (universal template naming, defaults still Danish), but remove
the compound_patterns= parameter. No caller passes a custom set, and a
fork adapting another locale edits the module-level constant either way,
so parameterizing it is speculative generality per CONTRIBUTING.md.

header_matches() now reads COMPOUND_PATTERNS directly. Test updated to
verify compound-vs-whole-token matching against the constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:06:05 +02:00
Sheroy Cooper 22a4438dab docs: prefer python3 in setup examples (#86) 2026-07-09 06:05:16 +02:00
Ayobami Adegoke e16afac7b9 ci: add supply-chain guards — permission allowlist, gitignore rules, manifest checks, pinned actions (#84)
* ci: add supply-chain guards - permission allowlist, gitignore rules, manifest checks, pinned actions

This template's threat model is unusual: it ships pre-approved Claude Code
permissions (.claude/settings.json) and CLI code that every fork user
executes via those permissions. A plausible-looking PR could therefore
ship risk to every forker: widen a permission to Bash(*), weaken the
personal-data gitignore rules, or smuggle code execution into bun install
via a lifecycle script. Nothing checked for these mechanically.

New job security-guards runs tools/security_guards.py (stdlib only):

- settings.json: every permissions.allow entry must be in an exact,
  in-repo allowlist. The guard makes permission changes loud, not
  impossible - a PR that intentionally widens permissions must update
  the allowlist in the same diff, so the widening is explicit and
  reviewable
- .gitignore: the personal-data rules (tracker, documents/**, cv/main_*,
  salary data, seen_jobs) must all still be present - the mirror image
  of the placeholder-integrity job
- .agents/**/package.json: no lifecycle scripts (preinstall/install/
  postinstall/prepare/prepack) and no trustedDependencies, which would
  execute arbitrary code during bun install on users' machines

New job dependency-review (PRs only): actions/dependency-review-action
flags newly introduced vulnerable or malicious dependencies,
fail-on-severity high.

Workflow hardening: explicit top-level permissions: contents: read
(least-privilege token), and all actions pinned to commit SHAs resolved
from the same major tags already in use (checkout v4, setup-python v5,
setup-bun v2), with the tag recorded in a comment.

Honest limit, recorded in the workflow header: a PR can edit this
workflow itself, so these guards catch accidents and casual attempts,
not a determined author. Branch protection with required checks and
human review of workflow/settings diffs remain the real backstop.

Verified locally: positive run passes; injecting Bash(*) into
settings.json, deleting the tracker gitignore rule, and adding a
postinstall script each fail the guard with the intended message, and
reverting restores a clean pass.

* ci: scope dependency-review to upstream PRs - forks lack Dependency graph by default

Verified on a fork: the action fails with 'Dependency review is not
supported on this repository' until Dependency graph is manually enabled,
and forks don't inherit it. Guarded with the same
github.repository == upstream condition the other upstream-only jobs use.
With the graph enabled the action passes, so the config itself is sound.

* ci: probe Dependency graph before dependency-review - warn and pass when unavailable

The upstream PR run showed Dependency graph is disabled on the upstream
repo too (the action hard-fails: 'Dependency review is not supported on
this repository'), not just on forks. Only the repo owner can enable it,
so a hard red X here is friction, not signal.

The job now probes the dependency-graph SBOM endpoint with the workflow
token first: HTTP 200 runs the real review; anything else emits a
::warning:: naming the setting to flip (Settings -> Advanced Security ->
Dependency graph) and passes. Same graceful-skip pattern the workflow
uses for optional tools - the check self-activates the moment the graph
is enabled, no workflow change needed.
2026-07-09 06:03:01 +02:00
Kushida 9e26de2c67 Fix salary tool edge cases (#75)
* fix: handle salary tool edge cases

* fix: preserve Danish salary compounds
2026-07-08 21:12:36 +02:00
Mads LorentzenandClaude Fable 5 a5ffcc39ff chore: untrack tracker CSV, scope scraper Bash permission, fix portal SKILL.md paths (#71)
- Untrack job_search_tracker.csv: it was both tracked and listed in
  .gitignore (same inconsistency class as the settings.local.json fix
  in #27). Users' personal rows risked merge conflicts on every pull;
  commands already create the file with the standard header when it
  is missing.
- Scope job-scraper's allowed-tools Bash entry (from #52) to
  'bun --version' and the portal-CLI invocation pattern, adopting the
  tighter form proposed in #65.
- Fix all five portal SKILL.mds documenting 'bun run skills/...'
  paths that do not resolve from the repo root ('.agents/skills/...'
  is correct) - now load-bearing since #52 wired /scrape to read
  these docs for CLI invocations. Surfaced in #66.
- Teach tools/lint_skills.py to glob-expand allowed-tools bun run
  targets so scoped wildcard permissions lint correctly.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:14:39 +02:00
Alwin4ZhangandAlwin.Zhang 3c7a1cfdf5 fix: Fix salary Excel column detection for index headers (#64)
改动点:
修复 [tools/convert_salary_excel.py (line 47)](/Users/alwin/ai-job-search/tools/convert_salary_excel.py:47) 里列类型识别的问题:之前 n 被当作任意子串匹配,导致 Index / Engineering Index 这类列会被误判成 count。
同步修复类别名生成,避免 Engineering Count 里的 n 被删坏。
把 openpyxl 缺失报错延迟到实际运行转换命令时,这样纯函数可以被单元测试导入。
新增 [tests/test_convert_salary_excel.py (line 25)](/Users/alwin/ai-job-search/tests/test_convert_salary_excel.py:25),覆盖 index/count 识别和 worksheet 解析。

Co-authored-by: Alwin.Zhang <alwin.zhang420@gmail.com>
2026-07-08 17:11:03 +02:00
Ayobami Adegoke 6477987608 ci: add GitHub Actions workflow - LaTeX smoke compiles, skill lint, CLI typechecks, placeholder integrity (#59)
Every PR to this repo is currently verified by hand. This adds the checks
a machine can do:

- latex-smoke: compiles cv/main_example.tex (lualatex) and the new
  cover_letters/cover_example.tex (xelatex) in the texlive/texlive
  container, failing on any LaTeX error. Exact page-count assertions
  (CV=2, cover letter=1) run on the upstream repo only
- lint (tools/lint_skills.py, also runnable locally): every SKILL.md has
  parseable YAML frontmatter with name+description (frontmatter breakage
  happened before - 37a0eed), allowed-tools 'bun run <path>' targets
  exist, command files start with a '# /<name>' title, settings.json is
  valid JSON with a permissions.allow list
- cli-typecheck: bun install + tsc --noEmit for all five portal CLIs
  (matrix, fail-fast off)
- placeholder-integrity (upstream only): tracked template files still
  carry their placeholder tokens, catching accidental personal-data
  commits before they land

Fork-friendly by design: /setup personalizes CLAUDE.md, the skill files,
and main_example.tex in forks, so placeholder checks and exact page
counts are guarded with github.repository == upstream; compile success
and lint run everywhere. Live CLI smoke tests are deliberately excluded:
network-flaky, and linkedin-search is personal-use-only per its own ToS
warning - CI-automated requests would violate it. CLIs are typechecked
instead.

The cover letter previously had no tracked example (cover_*.tex is
gitignored), so cover_example.tex is new: a placeholder letter following
the documented 06 structure, demonstrating the correct itemize-outside-
lettercontent pattern. It doubles as the structural reference /apply
Step 2 looks for on fresh clones, which until now matched nothing. The
gitignore exception is ordered after Cover_*.tex because case-insensitive
filesystems match that pattern against cover_example.tex too.

Writing it surfaced a latent bug in the documented template itself:
06-cover-letter-templates.md's structure ends with \closing{Kind
regards,\} - but cover.cls appends its own \, and the doubled break
produces '! LaTeX Error: There's no line here to end.' on every compile
(nonstopmode swallows it, so it went unnoticed). Fixed in 06 and noted
in the example.
2026-07-08 17:09:33 +02:00
AI Job SearchandClaude Opus 4.6 c66d599d75 Initial release: AI-powered job application framework
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:34:06 +01:00