diff --git a/README.md b/README.md index 5d540f6c..7f9aa932 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The first time, AIHawk asks for an [OrcaRouter](https://orcarouter.ai) key and k The first search with each new job database downloads its job index, 672 MB, into `~/.aihawk/data/index`; a search reads it on your computer. The descriptions of the jobs AIHawk judges are read from the release over the internet. -**Your data.** Your CV, your answers, the postings' texts and the pages' contents, screenshots included, go to the model gateway OrcaRouter and its providers; an application goes to the employer's site. The rest stays in `~/.aihawk`: the CV, the answers, the key (`key`), the proofs (`data/proofs/`), `applied.csv`, the job index and the browser profile with your sign-ins. You need a paid OrcaRouter key; a form costs cents in model calls. +**Your data.** Your CV, your answers, the postings' texts and the pages' contents, screenshots included, go to the model gateway OrcaRouter and its providers; an application goes to the employer's site. The rest stays in `~/.aihawk`: the CV, the answers, the key (`key`), the proofs (`data/proofs/`), `applied.csv`, the job index and the browser profile with your sign-ins. You need a paid OrcaRouter key. In our measured runs on eleven real sites, with deepseek-v4.1-flash, a form cost 0.4 to 3.3 cents in model calls (1.5 on average), and none of those runs reached the send. **Responsibility.** Applications go out in your name, and you answer for what is sent. Job sites have their own terms, and some do not allow automated applications: read them before you select their jobs. AIHawk asks once, with a yes, before it sends the selection; as a last resort the model can run a script in the page, which could send a form without asking (see [SECURITY.md](SECURITY.md)). The refusal of clicks that would send reads the control's words and type, so a control worded oddly, or a final button that only says Continue, can get past it. @@ -54,7 +54,7 @@ The first search with each new job database downloads its job index, 672 MB, int - **Finds** the postings whose titles fit the plan, in a job database published as a release: 10,223,753 postings in the manifest of 2026-11-04, built once by hand. Postings older than 60 days are left out by default. The daily rebuild, [`jobs-database.yml`](.github/workflows/jobs-database.yml), is on main, and its first scheduled run, on 2026-11-05, failed; it needs GitHub Actions working. - **Shortlists** the 100 postings with the best title fit: Jev, the decision model, answers six questions about each, and the best 25 are kept. - **Scores** each shortlisted job from 1 to 5 against your resume, with its gaps. -- **Applies**: writes a CV for the job and fills the form in a visible browser through [invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp). What a form asks and your files do not say, it asks you, and remembers. +- **Applies**: writes a CV for the job and fills the form in a visible browser through [invisible-playwright-mcp](https://github.com/feder-cr/invisible_playwright_mcp). What a form asks and your files do not say, it asks you, and remembers. The first apply or sign-in downloads the browser engine, about 268 MB on Windows. ## Sending @@ -74,6 +74,6 @@ Models go through OrcaRouter: `model` (default `deepseek/deepseek-v4.1-flash`) w - **Uninstall:** `pipx uninstall aihawk` (or `uv tool uninstall aihawk`), then delete `~/.aihawk`, which holds your CV, key and sign-ins. - **The search:** the plan is `filters.plan` in `~/.aihawk/aihawk.yaml`; restart `aihawk` after editing it. -More: the [wiki](https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk/wiki), [configuration](docs/configuration.md), [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [CHANGELOG.md](CHANGELOG.md). The job database merges public sources, whose licences and posting links are in [NOTICE](NOTICE) and [docs/data-sources.md](docs/data-sources.md). Some sources state no licence, and one asks that its postings not be republished as job listings; each posting belongs to its employer. The report's shape is adapted from [career-ops](https://github.com/santifer/career-ops) (MIT). +More: the [wiki](https://github.com/feder-cr/Jobs_Applier_AI_Agent_AIHawk/wiki), [configuration](docs/configuration.md), [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), [CHANGELOG.md](CHANGELOG.md). The job database merges public sources, whose licences and posting links are in [NOTICE](NOTICE) and [docs/data-sources.md](docs/data-sources.md). Some sources state no licence, and one asks that its postings not be republished as job listings; the published database includes that source. Each posting belongs to its employer. The report's shape is adapted from [career-ops](https://github.com/santifer/career-ops) (MIT). [MIT](LICENSE). The former Dots project is at [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots). diff --git a/SECURITY.md b/SECURITY.md index 9924c4fd..0082d76d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,7 @@ AIHawk reads job pages written by strangers, fills their forms, and sends applic - **A script can send a form.** As a last resort, the model may run a script in the page (`browser_evaluate`). The refusal in `apply.py` does not check a script, so a script can send a form without the person's yes. This is accepted on purpose: a script is the last rung, offered only when the other browser tools cannot do the job. After each call that changes the page, AIHawk checks whether the page left the form's site; it cannot tell a send on the same site. Report a case where a script sends something the person did not select as a vulnerability. - **The refusal of clicks reads words.** A final button that only says Continue is taken for the next step of a form, and a control worded in a way the check does not know can get past it. Refusing every Continue would stop every multi-step form. +- **Enter in a tag field can send the form.** Enter is allowed in a text field whose placeholder or label says to press Enter, and it is allowed on the assumption that the page's script catches it; where the script does not, the browser sends the form on Enter, and AIHawk does not refuse that. - **The mail plugins read the mailbox that the browser is signed into.** That is how a code or a link is taken from the mail. It is limited by the instructions in those plugins, not enforced by the browser or by the code. - **A captcha or a sign-in is not bypassed.** The model reports the form as blocked, and the person deals with it. - **The model is a third-party service.** Everything sent to it, including the CV and what the pages say, leaves the machine. The model's output is not trusted: the person selects, and the send is checked by AIHawk. diff --git a/src/aihawk/apply.py b/src/aihawk/apply.py index c18f6893..9800da9b 100644 --- a/src/aihawk/apply.py +++ b/src/aihawk/apply.py @@ -159,8 +159,13 @@ _APPLICATION = r"(?:application\w*|submission\w*|candidatur\w*|bewerbung\w*|doma _FINISH_ANY = re.compile(rf"\b{_FINISH_LATIN}\b|{_FINISH_CJK}", re.IGNORECASE) _FINISH_WHOLE = re.compile(rf"\W*(?:{_FINISH_LATIN}|{_FINISH_CJK})\W*", re.IGNORECASE) _FINISH_WITH_APPLICATION = re.compile(rf"\b{_FINISH_LATIN}\b(?:\W+\w+){{0,2}}?\W+{_APPLICATION}", re.IGNORECASE) -# Asking a site to mail a sign-in code sends no application. "Submit code" is left out: on some sites it does send. -_ASKS_FOR_CODE = re.compile(r"\b(?:send|invia|enviar|envoyer|senden)\b.*\b(?:code|codice|c[oó]digo|otp)\b|\bcode\s+senden\b", re.IGNORECASE) +# Asking a site to mail a sign-in code sends no application: only the verb, a few words of "me a" or "verification", and the +# code itself, with no room for anything else. "Submit code" is left out: on some sites it does send. +_ASKS_FOR_CODE = re.compile( + r"\b(?:send|invia|enviar|envoyer|senden|solicitar|solicite)\s+(?:(?:me|a|my|the|new|verification|sign-in|login|one-time|temporary)\s+){0,3}" + r"(?:code|codice|c[oó]digo|otp)\b|\bcode\s+senden\b", + re.IGNORECASE, +) _APPLY = re.compile(r"\b(apply|applica)\b", re.IGNORECASE) # What a button inside a form says when it does not send it: a step, an answer (Ashby's Yes and No are buttons with no # type), or a file to attach. Continue is a step: a submit-type Continue is the next step of a multi-step form far more @@ -190,8 +195,9 @@ def _inspect(found: str) -> str: def _asks_for_enter(element: dict[str, Any]) -> bool: """Whether the focused field asks for Enter in its own placeholder or label; the page's text around it is never read.""" - # Why it is safe enough: this field's Enter adds a tag. A page that lied here would only send a form it already has - # every right to, and the person has already said yes to sending this selection. + # Why it is allowed: the page's script is assumed to catch Enter and add the tag. Nothing checks that. Where no script + # catches it, the browser's implicit submission sends the form, with or without a submit button, and the send is not + # refused here. Reproduced on a local page; the limit is in SECURITY.md. if element.get("tag") not in {"input", "textarea"}: return False if element.get("tag") == "input" and element.get("type", "") not in TEXT_TYPES: @@ -210,8 +216,12 @@ def sends_form(element: dict[str, Any], form_touched: bool) -> bool: one. Confirm, Finish or Complete about something else is a step, as Continue is. A link is judged by its words alone. Otherwise a submit button, or a button with no type inside a form, sends unless its words name a step, an answer or a file.""" tag, kind, text = element.get("tag", ""), element.get("type", ""), element.get("text", "") - if _ASKS_FOR_CODE.search(text): - return False + code = _ASKS_FOR_CODE.search(text) + if code: + # The code request is taken out of the label, and what is left is judged by its words alone: a send word beside + # it ("Submit application and send code") still sends. A bare code button has no such word, so it does not send. + rest = f"{text[: code.start()]} {text[code.end():]}" + return bool(_SENDS.search(rest) or _FINISH_ANY.search(rest) or _APPLY.search(rest)) if _SENDS.search(text) or _finishes(text): return True # "Apply" opens the form from a job's page, and sends it once the form has been filled. diff --git a/tests/test_apply.py b/tests/test_apply.py index 8b39434a..86fb0322 100644 --- a/tests/test_apply.py +++ b/tests/test_apply.py @@ -803,7 +803,14 @@ def test_asking_for_a_sign_in_code_is_not_a_send_but_submitting_one_is() -> None def button(text: str) -> dict[str, object]: return {"tag": "button", "type": "submit", "in_form": True, "text": text} - for text in ("Send code", "Send verification code", "Invia codice", "Enviar código", "Code senden"): + for text in ( + "Send code", "Send verification code", "Send me a code", "Invia codice", "Enviar código", "Code senden", + "Solicitar código de verificación", + ): assert not sends_form(button(text), True), text - for text in ("Submit code", "Send", "Send application"): + # A send word beside the code request still sends: the request is taken out and the rest is judged. + for text in ( + "Submit code", "Send", "Send application", "Submit application and send code", "Send application and code", + "Solicitar empleo", "Solicitar", + ): assert sends_form(button(text), True), text