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
This commit is contained in:
Oscar Madera
2026-09-16 06:43:16 +02:00
committed by GitHub
parent 27eb57ae93
commit 88d1b61047
3 changed files with 161 additions and 5 deletions
+21 -5
View File
@@ -44,13 +44,29 @@ python salary_lookup.py "<Company Name>" --json
If the posting specifies a city, add `--city "<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. If the posting specifies a city, add `--city "<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 `.<apex>`. 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: <hostname> - 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: Present the evaluation to the user with:
1. **Skills match** - which required/preferred skills match vs. gaps 1. **Source host verification** - installed portal board, official ATS, or ⚠ unverified source host (named plainly)
2. **Experience match** - how work history maps to the role 2. **Skills match** - which required/preferred skills match vs. gaps
3. **Behavioral/culture match** - how behavioral profile fits the role/company culture 3. **Experience match** - how work history maps to the role
4. **Salary benchmark** - salary index for the company (if available) 4. **Behavioral/culture match** - how behavioral profile fits the role/company culture
5. **Overall fit score** and recommendation (strong fit / moderate fit / weak fit) 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: After presenting the evaluation, ask the user:
> "Should I proceed with drafting the CV and cover letter for this role?" > "Should I proceed with drafting the CV and cover letter for this role?"
+9
View File
@@ -15,6 +15,14 @@ per-file diff commands.
### Added ### 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: <hostname>`) before drafting tokens are spent.
- **`/expand` project and portfolio expansion** (`.claude/commands/expand.md`, - **`/expand` project and portfolio expansion** (`.claude/commands/expand.md`,
`tests/test_expand_command.py`) - expands candidate discovery `tests/test_expand_command.py`) - expands candidate discovery
to technical projects from public GitHub repositories, extracting structured summaries 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 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 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. not see its still-live postings re-presented as new.
## [1.7.1] - 2026-09-06 ## [1.7.1] - 2026-09-06
### Added ### Added
+131
View File
@@ -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()