AIHawk: offer browser_evaluate as the last rung, watched after each call

The model may run script again, by the owner's decision, knowing a script could send a form without the person's yes.
NEVER_OFFERED is gone. The system prompt says script comes only after selectors, coordinates and a screenshot have
failed, and never to send the application. browser_evaluate counts as acting, so a script that leaves the form's host
stops the form as unconfirmed. The docstrings and the README say what holds.
This commit is contained in:
feder-cr committed 2026-11-04 18:53:17 +01:00
1 parent 7179db6da6
commit e9a9c7746e
4 files changed
+54 -27

No files matched your search

+6 -6
View File
@@ -37,26 +37,26 @@ The first time, it asks for an [OrcaRouter](https://orcarouter.ai) key and keeps
## What it does
- **Reads** your resume and writes a search plan from it, and takes your contact details.
- **Finds** postings in a job database published as a release: `jobs-latest` points at a build with 10,223,753 postings (built once by hand on 2026-11-04). The daily refresh starts once the workflow is merged to main. Postings older than 60 days are left out by default.
- **Finds** postings in a job database published as a release: `jobs-latest` points at a build with 10,223,753 postings (built once by hand on 2026-11-04). The daily refresh starts once its workflow is on main. Postings older than 60 days are left out by default.
- **Judges** the postings that fit best, six questions each, with Jev through OrcaRouter.
- **Scores** each 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.
## Sending
The model never sends an application. It fills the form and names the submit button; AIHawk clicks it only after you press `y`.
The model fills the form and names the submit button; AIHawk clicks it only after you press `y`. As a last resort the model can run script, which could send a form without asking.
`aihawk --auto-submit` sends without asking. An application sent in your name can be wrong, and sites can close accounts over it: read the forms first.
`aihawk --auto-submit` sends without asking. A wrong application sent in your name can get an account closed: read the forms first.
## Sites
Plugins cover Ashby, BambooHR, Breezy, Greenhouse, JazzHR, Lever, Paylocity, Personio, Recruitee, SmartRecruiters, Teamtailor and Workable. Other postings are listed for applying by hand.
A plugin is a Markdown file in [`src/aihawk/plugins/`](src/aihawk/plugins): a prompt that explains one site's form. Anyone can add a site by adding a file, with no code. Your own go in `~/.aihawk/plugins/`.
A plugin is a Markdown file in [`src/aihawk/plugins/`](src/aihawk/plugins): a prompt that explains one site's form. Anyone can add a site with a file and no code. Your own go in `~/.aihawk/plugins/`.
## Plan and models
The search plan is an editable graph in `filters.plan` of `~/.aihawk/aihawk.yaml`; restart `aihawk` after you change it.
The search plan is an editable graph in `filters.plan` of `~/.aihawk/aihawk.yaml`; restart `aihawk` after editing it.
Models go through OrcaRouter: `model` (default `z-ai/glm-5.3-flash`) writes the CV and fills forms; `quick_model` (default `google/gemini-3.5-flash`) writes the plan; `decision_model` (default `typesafe/jev-1.14`) shortlists the jobs; `judge_model` (default `deepseek/deepseek-v4.1-flash`) scores them. Any OpenAI-compatible endpoint with tool calling and structured output can replace OrcaRouter through `base_url`.
@@ -64,7 +64,7 @@ Models go through OrcaRouter: `model` (default `z-ai/glm-5.3-flash`) writes the
The job database merges public job databases. Their sources, licences and the postings' links are in [NOTICE](NOTICE); each posting belongs to its employer. The job report's shape is adapted from [career-ops](https://github.com/santifer/career-ops) (MIT).
The Dots project that used to live here is at [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots).
The former Dots project is at [feder-cr/invisible_dots](https://github.com/feder-cr/invisible_dots).
## License
+13 -15
View File
@@ -1,10 +1,11 @@
"""Filling a job's application form with the model, through invisible-playwright-mcp.
The model never sends an application. It fills the form and names the submit control when it is done; this program
clicks that control, after the person's yes, or by itself when they asked for it with --auto-submit. While the model
works, a click on anything that would send the form and the Enter key are refused (`refusal`), so the one way an
application leaves is the click below. What the model writes comes from the CV and the answers file; what they do not
say comes back as a question for the person, never as a guess.
The model is told never to send an application. It fills the form and names the submit control when it is done; this
program clicks that control, after the person's yes, or by itself when they asked for it with --auto-submit. While the
model works, a click on anything that would send the form and the Enter key are refused (`refusal`), and the person's
yes guards that control. The model may run script in the page as a last resort, and that script could get around the
check on rare occasions and send a form without asking. What the model writes comes from the CV and the answers file;
what they do not say comes back as a question for the person, never as a guess.
"""
from __future__ import annotations
@@ -38,7 +39,8 @@ READ_WAIT_S = 1.0
# The server's tools that act and that `refusal` checks against the rule that the model does not send the form. A tool
# the server says only reads is offered as well, so one it adds tomorrow just works; one it adds that acts stays out
# until it is checked here. The server's own instructions say in which order to reach for them.
# until it is checked here. The server's own instructions say in which order to reach for them. browser_evaluate is the
# last rung, a script: `refusal` does not check it, and `_after` watches what it does.
MODEL_TOOLS = {
"browser_click_at",
"browser_take_screenshot",
@@ -51,6 +53,7 @@ MODEL_TOOLS = {
"browser_select_option",
"browser_upload_files",
"browser_press_key",
"browser_evaluate",
}
FINISH_TOOL = {
@@ -94,6 +97,8 @@ You fill in one job application form in a browser, for the candidate, using only
- You cannot send the application, and clicks on what would send it are refused. When every required field is filled,
call finish_form with status ready and the CSS selector of the submit control. If that control is disabled, the form
is not complete: find what it still wants, and if you cannot, call finish_form with status blocked and say so.
- Run script in the page (browser_evaluate) only after selectors, coordinates and a screenshot have failed, and never to
send the application.
- A captcha, a login wall or a closed posting: call finish_form with status blocked. Do not try to get around them.
- A code or confirmation link sent by email may be fetched as the instructions for that site say; a login wall on a site
where the person has not signed in is still blocked.
@@ -232,15 +237,8 @@ def instructions(guidance: str, page_rules: str = "") -> str:
return f"{text}\n\nAbout this site. Where this differs from the rules above, the rules above win:\n\n{guidance}"
# The server marks it as reading, and its refusal is narrow by its own account: `requestSubmit?.()` passes and sends the
# form. No check can tell what a script will do, and the other tools already read everything the model needs.
NEVER_OFFERED = {"browser_evaluate"}
def offered(tool: Any) -> bool:
"""Whether the model gets this tool of the server's."""
if tool.name in NEVER_OFFERED:
return False
return tool.name in MODEL_TOOLS or bool(tool.annotations and tool.annotations.readOnlyHint)
@@ -257,8 +255,8 @@ class LeftForm(Exception):
"""A call of the model's moved the page to another site: it may have sent the form."""
# The calls that can change the page. A navigation is the model's own choice, so it is not checked after: `refusal` judges it.
ACTING = {"browser_click", "browser_click_at", "browser_type", "browser_select_option", "browser_upload_files", "browser_press_key"}
# The calls that can change the page, a script included. A navigation is the model's own choice, so it is not checked after: `refusal` judges it.
ACTING = {"browser_click", "browser_click_at", "browser_type", "browser_select_option", "browser_upload_files", "browser_press_key", "browser_evaluate"}
PAGE = "({url: location.href, text: document.body ? document.body.innerText : ''})"
+3 -2
View File
@@ -8,8 +8,9 @@ name. AIHawk applies only where a plugin exists; other postings are listed for a
A plugin with `when: always` in its front matter has no hosts: its body is added to every form's instructions, beside the
site's own plugin, and it never makes a site count as one AIHawk can apply to. The mail plugins are of that kind.
A plugin only explains a site. What protects the person stays in apply.py, out of any plugin's reach: the model cannot
send the form, writes nothing that the CV and the answers do not say, and does not get around a captcha or a login.
A plugin only explains a site. What protects the person stays in apply.py, out of any plugin's reach: the model is told
not to send the form and clicks on what would send it are refused (a last-resort script could still get around that on
rare occasions), it writes nothing that the CV and the answers do not say, and it does not get around a captcha or a login.
"""
from __future__ import annotations
+32 -4
View File
@@ -1,4 +1,4 @@
"""The rule that matters: the model cannot send an application, and only a yes (or --auto-submit) does."""
"""The rule that matters: the model is held from sending an application, and only a yes (or --auto-submit) clicks the send control."""
from __future__ import annotations
@@ -214,10 +214,13 @@ def test_a_click_at_a_point_on_anything_else_passes() -> None:
assert asyncio.run(refusal(PageAtAPoint(field), "browser_click_at", {"x": 10, "y": 20}, True)) is None
def test_script_is_never_offered_because_no_check_can_tell_what_it_does() -> None:
def test_script_is_offered_as_the_last_rung_and_refusal_does_not_check_it() -> None:
tool = SimpleNamespace(name="browser_evaluate", annotations=SimpleNamespace(readOnlyHint=True))
assert not offered(tool)
assert "browser_evaluate" not in MODEL_TOOLS
assert offered(tool)
assert "browser_evaluate" in MODEL_TOOLS
# Known gap, written down: a script that sends the form is not refused, by design of the server's own check.
send = {"expression": "document.querySelector('#send').requestSubmit?.()"}
assert asyncio.run(refusal(PageAtAPoint(SUBMIT), "browser_evaluate", send, True)) is None
@pytest.mark.parametrize("arguments", [{"key": " "}, {"key": "Space"}, {"renamed": "space"}])
@@ -432,6 +435,31 @@ def test_a_change_event_that_moves_the_page_off_the_form_stops_as_unconfirmed_an
assert len(model.requests) == 1
class ScriptMoves(ScriptedBrowser):
"""A page whose script, when it runs `location.href = ...`, leaves for another site."""
def __init__(self, after: dict[str, str]) -> None:
super().__init__({"#send": SUBMIT}, page=FORM_PAGE)
self.after = after
async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
if name == "browser_evaluate" and str((arguments or {}).get("expression", "")).startswith("location.href ="):
self.calls.append((name, arguments or {}))
self.page = self.after
return SimpleNamespace(isError=False, content=[SimpleNamespace(text="ok")])
return await super().call_tool(name, arguments)
def test_a_script_that_moves_the_page_off_the_form_stops_as_unconfirmed_and_nothing_is_retried() -> None:
browser = ScriptMoves(after={"url": "https://thanks.example/ok", "text": "Thanks"})
model = ScriptedModel([tool_call("browser_evaluate", expression="location.href = 'https://thanks.example/ok'")], ready(), VERIFIED)
outcome = run_with(model, browser)
assert outcome.status == "unconfirmed"
assert "thanks.example" in outcome.summary
assert browser.clicks() == []
assert len(model.requests) == 1
def test_a_change_that_stays_on_the_site_goes_on_to_the_form_so_a_same_site_send_is_not_detected() -> None:
# The address alone cannot tell a multi-step form from a send that stays on the site: this is the gap, written down.
browser = Moves(after={"url": "https://u/apply", "text": "Thank you, your application was received"})