From 88d1b610477206b95f0f168037a085db3ead76f0 Mon Sep 17 00:00:00 2001 From: Oscar Madera <80536682+oscarbol09@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:43:16 -0500 Subject: [PATCH] feat(apply): verify posting source host against installed portals and known ATS apexes (#431) (#467) - Add source host verification rule to apply.md Step 1 before drafting - Check posting URL host against installed portals and six official ATS apexes - Enforce fail-closed look-alike parsing (prefix, suffix, userinfo spoofing) - Plainly flag unverified third-party hosts in evaluation output - Add tests/test_apply_host_check.py and update CHANGELOG.md --- .claude/commands/apply.md | 26 +++++-- CHANGELOG.md | 9 +++ tests/test_apply_host_check.py | 131 +++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 tests/test_apply_host_check.py diff --git a/.claude/commands/apply.md b/.claude/commands/apply.md index 4e25cee..49af6f6 100644 --- a/.claude/commands/apply.md +++ b/.claude/commands/apply.md @@ -44,13 +44,29 @@ python salary_lookup.py "" --json If the posting specifies a city, add `--city ""` to narrow results. Parse the JSON output and include the salary benchmark in the evaluation. If the tool is not configured or returns an error, skip the salary benchmark. +### Source Host Verification (when input is a URL) + +Before proceeding to drafting, inspect the posting URL's hostname to verify provenance (#431). Classify the host into one of three categories: + +1. **Installed portal board:** the host matches any configured job portal in `.agents/skills/` (e.g. `jobindex.dk`, `linkedin.com`, `jobnet.dk`, `jobbank.dk`, `jobdanmark.dk`, `freehire.me`, or any portal added by `/add-portal`). +2. **Known official ATS apex:** the host matches or is a valid subdomain of one of the six standard ATS domains: + - `greenhouse.io` + - `lever.co` + - `myworkdayjobs.com` (or `workday.com`) + - `ashbyhq.com` + - `smartrecruiters.com` + - `workable.com` + *Look-alike parsing:* the host must match the apex exactly or end with `.`. Look-alike prefix tricks (e.g. `evil-greenhouse.io`), suffix spoofing (e.g. `job-boards.greenhouse.io.evil.com`), userinfo tricks (`https://greenhouse.io@evil.com/`), and unfamiliar subdomains fail closed and must not be classified as an official ATS. +3. **Neither (Unverified host):** name the host plainly in the evaluation output as unverified (`⚠ Unverified source host: - not an installed portal board or known ATS apex`). Alert the user to verify the employer and link legitimacy before committing time and tokens to drafting. + Present the evaluation to the user with: -1. **Skills match** - which required/preferred skills match vs. gaps -2. **Experience match** - how work history maps to the role -3. **Behavioral/culture match** - how behavioral profile fits the role/company culture -4. **Salary benchmark** - salary index for the company (if available) -5. **Overall fit score** and recommendation (strong fit / moderate fit / weak fit) +1. **Source host verification** - installed portal board, official ATS, or ⚠ unverified source host (named plainly) +2. **Skills match** - which required/preferred skills match vs. gaps +3. **Experience match** - how work history maps to the role +4. **Behavioral/culture match** - how behavioral profile fits the role/company culture +5. **Salary benchmark** - salary index for the company (if available) +6. **Overall fit score** and recommendation (strong fit / moderate fit / weak fit) After presenting the evaluation, ask the user: > "Should I proceed with drafting the CV and cover letter for this role?" diff --git a/CHANGELOG.md b/CHANGELOG.md index f89d4d8..68629a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ per-file diff commands. ### Added +- **Source host verification in `/apply` Step 1** (#431, `.claude/commands/apply.md`, + `tests/test_apply_host_check.py`) - before proceeding to draft CV and cover letters, + Step 1 verifies the posting URL's provenance against installed portal boards and the + six standard ATS apex domains (`greenhouse.io`, `lever.co`, `myworkdayjobs.com`/`workday.com`, + `ashbyhq.com`, `smartrecruiters.com`, `workable.com`). Look-alike prefix/suffix spoofing + fails closed, and unrecognized hosts are plainly flagged as unverified in the evaluation + output (`⚠ Unverified source host: `) before drafting tokens are spent. + - **`/expand` project and portfolio expansion** (`.claude/commands/expand.md`, `tests/test_expand_command.py`) - expands candidate discovery to technical projects from public GitHub repositories, extracting structured summaries @@ -171,6 +179,7 @@ per-file diff commands. Existing state files need no migration: Step 2's candidate filter matches a posting to a stored entry by URL regardless of that entry's key, so a workspace whose entries predate the helper does not see its still-live postings re-presented as new. + ## [1.7.1] - 2026-09-06 ### Added diff --git a/tests/test_apply_host_check.py b/tests/test_apply_host_check.py new file mode 100644 index 0000000..a54f846 --- /dev/null +++ b/tests/test_apply_host_check.py @@ -0,0 +1,131 @@ +"""Guards for /apply's source host verification rule in Step 1 (#431). + +Pins the invariants from the maintainer design in issue #431: +- URLs must be verified before drafting against installed portal boards or known ATS apexes. +- The 6 standard ATS apex domains must be checked: greenhouse.io, lever.co, + myworkdayjobs.com (or workday.com), ashbyhq.com, smartrecruiters.com, workable.com. +- Look-alike attacks (prefixes, suffixes, userinfo tricks) must fail closed. +- Any other host must be named plainly in the output as unverified. +""" + +import re +import unittest +from pathlib import Path +from urllib.parse import urlparse + +REPO = Path(__file__).resolve().parent.parent +APPLY_COMMAND_FILE = REPO / ".claude" / "commands" / "apply.md" + +KNOWN_ATS_APEXES = { + "greenhouse.io", + "lever.co", + "myworkdayjobs.com", + "workday.com", + "ashbyhq.com", + "smartrecruiters.com", + "workable.com", +} + +SHIPPED_PORTAL_HOSTS = { + "jobindex.dk", + "linkedin.com", + "jobnet.dk", + "jobbank.dk", + "jobdanmark.dk", + "freehire.me", +} + + +def classify_posting_host(url_str: str, installed_portals: set[str] = SHIPPED_PORTAL_HOSTS) -> tuple[str, str]: + """Reference implementation of the host provenance rule in /apply Step 1. + + Returns (tier, host), where tier is one of: + - 'installed_portal' + - 'official_ats' + - 'unverified' + """ + try: + parsed = urlparse(url_str) + host = (parsed.hostname or "").lower().strip() + except Exception: + return "unverified", "" + + if not host: + return "unverified", "" + + # Check installed portal boards (exact match or subdomain match) + for portal in installed_portals: + if host == portal or host.endswith(f".{portal}"): + return "installed_portal", host + + # Check known official ATS apexes (exact match or subdomain match) + for apex in KNOWN_ATS_APEXES: + if host == apex or host.endswith(f".{apex}"): + return "official_ats", host + + return "unverified", host + + +class ApplyHostVerificationSpecTests(unittest.TestCase): + def setUp(self): + self.text = APPLY_COMMAND_FILE.read_text(encoding="utf-8") + step1_match = re.search(r"## Step 1: DRAFTER - Evaluate Fit(.*?)(?=## Step 2:)", self.text, re.DOTALL) + self.assertTrue(step1_match, "Step 1 must exist in apply.md") + self.step1_text = step1_match.group(1) + + def test_step1_contains_source_host_verification_heading(self): + self.assertIn("Source Host Verification", self.step1_text) + + def test_step1_documents_all_six_ats_apexes(self): + for apex in ["greenhouse.io", "lever.co", "myworkdayjobs.com", "ashbyhq.com", "smartrecruiters.com", "workable.com"]: + self.assertIn(apex, self.step1_text, f"Step 1 must specify ATS apex: {apex}") + + def test_step1_documents_look_alike_fail_closed_rules(self): + self.assertIn("evil-greenhouse.io", self.step1_text) + self.assertIn("fail closed", self.step1_text) + + def test_step1_requires_unverified_hosts_to_be_named_plainly(self): + self.assertIn("Unverified source host", self.step1_text) + + def test_classifier_identifies_official_ats_subdomains(self): + urls = [ + "https://boards.greenhouse.io/acme/jobs/12345", + "https://job-boards.greenhouse.io/acme/jobs/12345", + "https://jobs.lever.co/corp/67890", + "https://acme.myworkdayjobs.com/en-US/Careers/job/1", + "https://jobs.ashbyhq.com/startup/abc-123", + "https://jobs.smartrecruiters.com/Enterprise/456", + "https://apply.workable.com/tech-corp/j/789/", + ] + for url in urls: + tier, host = classify_posting_host(url) + self.assertEqual(tier, "official_ats", f"{url} should classify as official_ats, got {tier}") + + def test_classifier_identifies_installed_portal_hosts(self): + urls = [ + "https://www.jobindex.dk/jobannonce/12345", + "https://www.linkedin.com/jobs/view/999999", + "https://jobnet.dk/find-job/8888", + "https://jobbank.dk/job/7777", + "https://freehire.me/job/6666", + ] + for url in urls: + tier, host = classify_posting_host(url) + self.assertEqual(tier, "installed_portal", f"{url} should classify as installed_portal, got {tier}") + + def test_classifier_fails_closed_on_look_alikes_and_unverified_hosts(self): + suspicious = [ + "https://evil-greenhouse.io/job/1", + "https://boards.greenhouse.io.evil.com/job/1", + "https://boards.greenhouse.io@evil-domain.com/job/1", + "https://myworkdayjobs.com.phishing.net/login", + "https://lever.co.attacker.org/apply", + "https://unknown-board.example.com/posting/123", + ] + for url in suspicious: + tier, host = classify_posting_host(url) + self.assertEqual(tier, "unverified", f"{url} must fail closed as unverified, got {tier}") + + +if __name__ == "__main__": + unittest.main()