mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-08 07:57:40 +00:00
01a3c0e5f6ebeab05bab6046c2bdee02370a760c
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
01a3c0e5f6 |
security: narrow skill grants, ship ask/deny rules, guard skill frontmatter, GET-only 403 retry (#642)
* security(skills): drop bare Bash and the portal CLI glob from skill grants A skill's allowed-tools pre-approves the listed tools for the turn that invokes it, even in an untrusted folder. job-application-assistant granted bare Bash, Edit and Write; job-scraper granted bun run on any .agents/skills/*/cli/src/cli.ts. Every Bash command the skills document is already allowlisted per command in .claude/settings.json, so the bare and glob grants bought nothing but exposure. The per-portal grants stay in settings.json, which is where a portal added by /add-portal registers too (Step 5 now says so). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * security(settings): deny exfil-shaped shell and credential reads, always ask on permission-defining files Deny rules block in every permission mode including bypassPermissions and override any allow rule or skill grant; ask rules force a prompt even when a skill's allowed-tools would pre-approve the call. settings.json now ships both: deny for shell that uploads or opens a remote shell (curl data/form/upload flags, wget post, scp, sftp, ssh, nc) and for reads of .env and ~/.ssh, ~/.aws, ~/.gnupg; ask for the files whose content is the permission surface (settings, skill frontmatter, agents, CI, this guard) and for git push. security_guards.py requires each rule to stay present, the same way it requires the personal-data gitignore rules. Prefix rules are tripwires, not walls; SECURITY.md will say so. Also allowlists tools/robots_check.py and the upcoming tools/fetch_page.py so the 403 retry no longer needs a curl grant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * security(guards): hold skill and command allowed-tools to a reviewed allowlist The guard allowlisted settings.json permissions but never read skill frontmatter, so the narrow settings allowlist sat next to a bare Bash grant it could not see. Check 4 parses every .claude/skills/*/SKILL.md, .agents/skills/*/SKILL.md and .claude/commands/*.md frontmatter with a stdlib parser (scalar, folded, and list forms) and holds each allowed-tools entry to ALLOWED_SKILL_TOOLS; a portal skill may additionally grant exactly its own cli/src/cli.ts, matched by path so no portal name is hardcoded. Bare Bash and mid-pattern wildcards get their own message. On master's frontmatter this check reports two failures. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(tools): add GET-only fetch_page.py for the browser-header retry 09-web-research.md documented the 403 retry as a curl command with browser headers plus an inline python3 -c tag-stripper, which meant the workflow wanted a Bash(curl:*) grant - and curl with a free tail is an upload channel. fetch_page.py is the same retry as one command with a fixed GET argv (URL after --, bounded to 45 s / 5 redirects / 10 MB), http(s) only, robots_check.gate() enforced with no bypass flag, --out refused inside the repo, and --text for the stripped page. urllib fallback when curl is absent. The settings.json grant names the script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(security): describe the permission surface as a table; route the 403 retry through fetch_page.py SECURITY.md now lists every shipped grant with its location, lifetime and guard, and says plainly what the permission layer does not do: no sandbox on native Windows, prefix rules are tripwires, WebFetch is unrestricted by domain, subagents run with the parent's tools (next change), and Edit/Write stay granted because tailoring a CV is editing files. 09-web-research.md's retry section is the one fetch_page.py command (framework_version 1.1.3 -> 1.1.4); apply, rank and scrape point at it instead of curl. SETUP.md and the README tools tree follow. CHANGELOG gains a Security section under [Unreleased]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * security(review): ask on command frontmatter, portal skills grant only their CLI, bound the urllib fallback Findings from a fresh-context review of the branch: - A command's frontmatter is a grant too (check 4 already parses it), so Edit(.claude/commands/*.md) joins the required ask rules; without it a steered session could write `allowed-tools: Bash` into apply.md with no prompt and the next /apply would be pre-approved for bare Bash. - SECURITY.md promised a portal skill may grant "its own CLI and nothing else"; the guard now enforces exactly that instead of own-CLI plus the core allowlist. All six shipped portal skills already comply. - fetch_page.py's urllib fallback now matches curl's 5-redirect cap and applies the 10 MB cap after decompression, so the docstring's bounds hold on both paths. - Frontmatter parser: a BOM no longer reads as "missing frontmatter", YAML flow lists split correctly, and a non-UTF-8 file fails with a message instead of a traceback. Module docstring lists check 4. - CHANGELOG no longer claims "no new prompt": compile and aux cleanup, which the removed bare Bash covered in a skill-invoking turn, now prompt, deliberately. "Once per session" wording corrected to per call. - Three upload-shaped deny siblings added (curl -XPOST, curl --request POST, wget --method=POST); /add-portal recon points at fetch_page.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cbd8a991ab |
feat(layout): measure compiled PDF layout instead of eyeballing it (#378)
* feat(layout): measure compiled PDF layout instead of eyeballing it /apply Step 5b asks for layout properties and executes none of them: they are checked by reading the rendered page, which is exactly how they get missed. The failure that motivates this is silent under every existing check. A moderncv \cventry renders as a tabular, so an entry is one unbreakable block; when it does not fit in the space left, the whole entry moves to the next page and leaves a hole behind. The document still compiles, still reports the expected page count, and still passes tools/verify_pdf.py. Observed in the wild at 273pt, roughly 19 blank lines, mid-page, on a CV whose visual read looked fine. tools/verify_layout.py reports per page where the text starts and stops, bottom whitespace as a share of page height, and the largest gap between lines, then exits 1 on a hole over 100pt, a non-final page ending more than 25% early, body text colliding with the page-number footer, a final page more than 35% empty, or an entry header or section heading stranded at a page break. Page count is deliberately not checked here - verify_pdf.py --pages already does that, and two implementations of one rule drift. Geometry comes from Poppler pdftotext -bbox, already a dependency; a missing Poppler exits 2 with "skipped:" rather than failing the run. Tests build synthetic Page/Line geometry, so the suite needs neither Poppler nor a LaTeX toolchain and runs on the existing 3.10-3.14 matrix. * fix(layout): survive Windows encoding and a pdftotext without -bbox Review found two failures on the repo's primary platform: subprocess.run(..., text=True) decoded pdftotext's UTF-8 output with the Windows ANSI codepage and crashed on the stock cv/main_example.pdf. It now passes encoding="utf-8" with errors="replace" at the call site, matching the fix verify_pdf.py already carries from #369. Git for Windows ships an xpdf-based pdftotext that shadows Poppler in a default PATH and has no -bbox flag; it exited 99, the CalledProcessError escaped, and the run ended in exit 1 - indistinguishable from a real layout problem, which would send /apply chasing a phantom hole. That now routes to the existing "skipped:" exit 2 path with a message naming the likely cause, covered by three tests on the extractor-failure path. Also from review: - .claude/settings.json and security_guards.py gain the verify_layout.py permission entries. apply.md Step 5b runs the tool on every /apply, so without them every run prompts. - The docstring and CHANGELOG no longer call Poppler a dependency verify_pdf.py relies on. Since #369 verify_pdf prefers pypdf and Poppler is the fallback; word bboxes have no pypdf equivalent, so this is the one step that still wants it, and that is now what the text says. - Docstring and apply.md state that the thresholds are calibrated for the stock moderncv and cover.cls geometry, and that the shipped example CV fails the thin-final-page rule by design. - largest_gap documents that it measures top-to-top, so a tall line inflates the gap by its own height - over-detection, the safe direction. - The test module imports via tools.verify_layout like test_verify_pdf.py instead of sys.path.insert. * fix(verify_layout): quote only the first stderr line in the skip message (#378) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi --------- Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8c81edc330 |
fix(scrape): make seen_jobs.json keys a pure function of the posting (#441)
* fix(scraper): make seen_jobs.json keys a pure function of the posting, and clean up the drift
The dedup key was prose only ("<url_or_company_title_key>"), so different
/scrape runs slugified company+title differently and the state file
accumulated two failures: keys carrying "&", "/", "," and ":" that break
the archive-folder path /apply and /outcome derive from company+role
(documents/README.md's subfolder rule exists because of exactly this),
and the same posting stored twice under two different truncations of a
long title (two Deloitte entries, one job, one URL).
tools/job_key.py makes the key a pure, deterministic function of
company+title+url: a strict allowlist slug, length-capped with a hash of
the full slug so truncation never collides across runs, and a fallback
to the portal's numeric job id when a non-Latin title slugifies to
nothing (a real prior entry, "securion_", would have collided with
every future non-Latin posting from that company).
--audit finds both failure classes in an existing seen_jobs.json without
guessing at a fix: malformed keys (real damage), a legacy three-part
company_title_location shape (harmless but not what the current rule
produces, so it silently re-duplicates on the next scrape), and
duplicate URLs. Ran it against this workspace's file and re-keyed the 15
entries it found - 7 malformed, 8 legacy-shape - verified byte-for-byte
against the pre-cleanup copy that no entry's data changed, only its key.
tests/test_job_key.py (16 tests) covers the slugify rules, the
truncation-hash behavior, both non-Latin fallback paths, and the audit
CLI's exit codes.
* fix(scrape): call the key helper from Step 4 instead of slugifying ad hoc
The helper added in the previous commit is only load-bearing if the spec
calls it. Step 4 described the key as prose ("<url_or_company_title_key>"),
which is what let each run slugify its own way. Step 4 now names the
command, and the schema shows the key's provenance.
Renumbers the trailing list item; no other behaviour in the step changes.
* docs(changelog): record the job-key rule under Unreleased
* fix(scrape): preserve dedup continuity across key rule
* changelog: note that existing seen_jobs.json entries need no migration (#441)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi
---------
Co-authored-by: nox <nox@Mac.home>
Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
3bf41149e0 |
fix(rank): move seen_jobs.json read/write off /rank's per-run cost (#395) (#425)
* fix(rank): move seen_jobs.json read/write off the state file's own critical path (#395) /rank's Step 1 read the whole of seen_jobs.json into the conversation to select candidates by eye, and Step 4 emitted it back to record scores. That cost is paid on every run regardless of how many jobs are scored, and it grows for the life of the workspace, since the file is append-only and most stored entries are `skipped`. tools/rank_state.py moves that traffic into code: - `candidates` selects entries per Step 1's existing rules (status filter, tracker exclusion, focus filter, `--limit`/`--all` from #424) and projects only the fields a scoring agent needs. - `sweep` runs rule 6's expiry pass over entries the run did not re-score - a stored-date comparison, no fetch, no agent - preserving its defensive parsing of non-ISO deadlines and its `--all` reversibility. - `apply` writes scoring results back atomically and prints the ranked/vetoed/expired rows Step 5's report is built from, preserving Step 4's existing write-back rules exactly: the `location` -> `location_verdict` legacy migration, the deadline null-is-not-a-correction rule, verbatim strengths/gaps persistence, and idempotent re-scoring. Step 1, Step 3's rule 6, and Step 4 now route through the tool instead of describing a manual read/write. Nothing about scoring policy changes - no new status, no new persisted field, no change to what counts as a veto. The tracker stays read-only and every write is atomic (temp file + rename). tests/test_rank_state.py (25 tests) covers the three subcommands directly. The new spec-guard class in test_rank_command.py derives the fields Step 4 must preserve from Step 2's own JSON schema block rather than retyping them as a second list, so a future edit to that contract is what the test reads instead of something that can drift from it. * fix(rank): add CHANGELOG entry and remove the undefined $SCRATCHPAD reference Two mechanical fixes from review: - Step 4 named the results hand-off file via $SCRATCHPAD, a variable nothing in the repo defines - a reader following the spec literally has no path to substitute. Named the location in prose instead (a temporary file outside the repo tree, never committed) and replaced the shell-variable-looking path in the example command with an explicit placeholder. - Added the [Unreleased] entry this change was missing; the one already in the diff belongs to #424. * changelog: fold the #395 entry into the existing Fixed section Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013fqqLgQSnwgWkv98twQhHi --------- Co-authored-by: nox <nox@Mac.home> Co-authored-by: Mads Lorentzen <madslorentzen17@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
2d636c50bf |
security: narrow Bash(bun run:*) to the six shipped portal CLIs (#396)
The upstream template pre-approves `Bash(bun run:*)`, which auto-approves `bun run <any file>` — arbitrary TypeScript from anywhere on disk — on every fork. Each portal SKILL.md already declares the tight form in its own allowed-tools; this makes settings.json agree with them. Blast radius drops from "any file on the machine" to the repo's own CLIs, with no new prompts in the /scrape path. tools/security_guards.py's ALLOWED_PERMISSIONS is updated in the same commit, as its docstring requires. Local: security_guards OK, lint_skills OK, 318 tests pass. Claude-Session: https://claude.ai/code/session_01HHqEAQqGS2KKXASiYcrAHQ |
||
|
|
dea8140db2 |
feat(ats): extract PDF text with pypdf before Poppler (#369)
* feat(ats): extract PDF text with pypdf before Poppler Lead the ATS text-layer check with pypdf (BSD, optional pip install). Fall back to pdftotext -layout -enc UTF-8. No cache directory, no installer, no AGPL pymupdf. Windows users without Poppler still get a mechanical parseability check; visual review remains the last resort. * Update verify_pdf.py * Update apply.md * Update verify_pdf.py * Update verify_pdf.py |
||
|
|
2551824ecb |
feat: add ATS & keyword verification pass to /apply's compile-and-inspect loop (#40)
An ATS reads the compiled PDF's embedded text layer, not the rendered page, and LaTeX can silently produce PDFs whose text extracts as garbage: icon glyphs where contact details should be, (cid:*) markers from fonts without Unicode mappings, interleaved lines from multi-column layouts. This matters more now that /add-template lets users bring arbitrary templates. The existing Step 5 loop verifies what a human sees; this adds verification of what a parser sees. New Step 5d in /apply (CV only - cover letters rarely go through keyword screening; cleanup renumbered to 5e): - Extract the CV PDF's text layer with pdftotext -layout. pdftotext (poppler) is an optional dependency: if missing, the mechanical check is skipped with a warning and keyword coverage falls back to the visual PDF read - the same graceful-skip pattern as salary_lookup.py - Parseability checks verified against a real extraction of the stock template: email/phone must survive as literal text (fontawesome icons extract as harmless glyph-name noise like MOBILE-ALT/Envelope, but a contact detail carried only by an icon or hyperlink is invisible to ATS), no (cid:*) or replacement-character garbage, reading order matching visual order, dates present - Keyword coverage reuses the required/preferred list from Step 1, matched in the posting's language, reported as covered / synonym-only / missing-have-it / missing-gap. Honesty rule enforced: keywords the profile genuinely supports get added to experience bullets; genuine gaps stay visible, never stuffed Integration: CLAUDE.md verification checklist section, ATS Parseability guidance in 05-cv-templates.md, narrow Bash(pdftotext:*) entry in the pre-approved permissions (keeping with the tightened scope from #27), cv/*.txt gitignored (extraction is personal data; also deleted by the step itself), and optional-dependency docs in README and SETUP. |
||
|
|
f15b9fb65d |
Tighten pre-approved permissions and untrack settings.local.json (#27)
* fix: move scoped permissions to settings.json, drop curl, untrack settings.local.json Addresses #23: - Remove pre-approved Bash(curl:*) - no agent workflow uses curl, and a toolkit that routinely feeds untrusted job postings to the model should not ship a pre-approved exfiltration-capable command - Move shared permissions to .claude/settings.json (committed by convention) and scope them tighter: Bash(bun run:*) for the job portal CLIs, Bash(python/python3 salary_lookup.py:*) for salary lookups - Untrack .claude/settings.local.json - it was committed despite being listed in .gitignore; the file stays local for personal overrides Reported-by: @josealfonsomora Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(setup): warn existing cloners about stale settings.local.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |