Threat Model & Security Engineering
JobCtrl is a local-first application, and its security model follows from that: the trust boundary is the developer's or user's own machine, not a network perimeter. Ordinary web and CLI API access is intentionally protected by locality rather than identity; browser-extension routes add a scoped local capability token without changing the loopback posture. This page explains what enforces that boundary, how the highest-risk path (apply) is contained, which integrity gates double as security controls, the hygiene rules that keep private data out of the repository, and which seams change the posture if JobCtrl is ever hosted.
Read this if you are changing the API surface, the apply path, or credential and data handling, and need to know which boundary keeps private data on the machine.
The user-facing companion is the user Security page; the local data inventory is in Data & Safety.
Repository Threat Model
Overview
JobCtrl is a local-first job-search automation system. A React/Vite UI talks to a loopback Fastify API, the API reads SQLite projections and starts work through JSON-RPC/Temporal, and a Python worker performs discovery, enrichment, scoring, resume and cover-letter generation, PDF rendering, Gmail-assisted verification, and guarded apply automation. A Chromium extension can capture the active page and run deterministic autofill against the local API.
Primary assets are the candidate profile and resume baseline, generated resumes and cover letters, application history, SQLite event/projection data, local settings, LLM/API/CAPTCHA keys, Gmail OAuth tokens, browser profiles and cookies, apply-worker state, prompt/completion traces, and the user's reputation with employers.
The supported deployment is a single-user workstation with data under ~/.jobctrl/. JobCtrl is not designed as an internet-facing or multi-user service. Security severity is therefore calibrated around remote job-board or employer content, malicious pages visited by Playwright/Chrome, malicious emails/PDFs/HTML captures imported by the user, local browser pages attempting to reach loopback services, and accidental export of private local data.
Threat Model, Trust Boundaries, And Assumptions
The core trust boundary is "a local process reading and writing local data on a single-user machine." The adversaries JobCtrl defends against are other local processes reaching the API without going through product gates, browser pages reaching the API through DNS rebinding or CSRF, untrusted job postings steering LLM or browser automation, malicious or malformed user-imported documents, and private data accidentally leaving the machine through git, telemetry, LLM providers, or agent/tool output.
JobCtrl does not defend against a compromised OS account, an attacker with local disk access, or an operator who intentionally writes malicious values into the local database, config, or workspace. Local data is not encrypted at rest, so those are out of scope for the local-only product unless the issue crosses a new boundary such as remote code execution, secret exfiltration, or real application submission without informed approval.
Inputs fall into three groups:
- Attacker-controlled inputs: public job-board and ATS pages, page JavaScript, redirects, JSON-LD/API responses, discovered job titles, descriptions, URLs, apply URLs, extension-captured page text, employer emails scanned for verification or outcomes, user-imported PDFs and saved HTML, and all LLM/model outputs. These values are stored in SQLite, rendered in the UI, used in prompts, and sometimes become browser navigation targets.
- Operator-controlled inputs: local UI/API/CLI requests, profile/settings values, source registry entries,
~/.jobctrl/.env, provider endpoints and model choices, resume template/style values, extension pairing tokens, and Apply Review decisions. If an attacker can modify these directly, they have substantial local control already. - Developer-controlled inputs: docs, tests, fixtures, release scripts, build scripts, and packaged defaults. Findings limited to these are normally low risk unless the data is reachable from production runtime or the release path can publish private artifacts or secrets.
External services are separate trust boundaries: LLM providers, Gmail, Google Maps, CAPTCHA providers, Langfuse/OpenTelemetry endpoints, public job boards and ATS APIs, Temporal, the system claude or SDK-bundled Claude runtime, Chrome, and any configured network proxy. Tenant IDs are currently the constant local; cross-tenant isolation is roadmap/hosted-mode posture, not a current local-mode boundary.
Locality is enforced structurally for ordinary local callers:
| Control | Mechanism | Where |
|---|---|---|
| Loopback bind | The API binds 127.0.0.1 by default and refuses a non-loopback host unless JOBCTRL_API_ALLOW_REMOTE_BIND is set. | apps/api/src/config.ts |
| Host-header allowlist | Requests whose Host is not 127.0.0.1, localhost, or [::1] are rejected as forbidden_host. This is the DNS-rebinding defense. | apps/api/src/server.ts, apps/api/src/local-origin.ts |
| Peer-address validation | Requests whose actual socket peer is not loopback are rejected as forbidden_remote_client, so a remote caller cannot satisfy locality by forging a loopback Host. | apps/api/src/server.ts, apps/api/src/local-origin.ts |
| Origin/Referer check | Unsafe mutation requests require a first-party local web Origin/Referer; arbitrary loopback web origins and no-token headerless clients are rejected as cross_site_request. | apps/api/src/server.ts, apps/api/src/local-origin.ts |
| Fetch metadata check | Unsafe browser mutations that carry Sec-Fetch-Site metadata must use a trusted value unless the request is a trusted extension request. | apps/api/src/server.ts |
| Local capability token | Authenticated /v1/extension/* routes require a bearer token stored under ~/.jobctrl/; non-browser local clients can also use it for unsafe mutations when no browser origin/fetch metadata is present. The loopback Host gate still applies. | apps/api/src/server.ts, apps/api/src/extension-auth.ts, apps/api/src/local-origin.ts |
| Worker public-page egress guard | Contact-research public-page fetches reject loopback, private-network, link-local, reserved, unspecified, multicast, and metadata targets before fetching, disable automatic redirects, and re-run the source policy plus DNS/public-address check for each redirect target. | workers/automation/src/jobctrl/domain/contact/source_policy.py, workers/automation/src/jobctrl/infrastructure/contact/research_fetcher.py |
| Worker-readiness gate | Worker-backed action routes return 503 worker_runtime_unavailable until a healthy worker heartbeat exists. | apps/api/src/server.ts, GET /v1/health |
The loopback assumption is load-bearing
Be honest about the limits. This posture is safe only while the API stays on loopback; the moment it is exposed remotely, the loopback assumption breaks and real authentication is required (see Hosted-Future Posture). Hosted auth, tenant isolation, an encrypted secret vault, and an audit log are roadmap items, not current guarantees — see SECURITY.md and the SaaS section of the backlog. Local data at rest is not encrypted.
Attack Surface, Mitigations, And Attacker Stories
Local API, web UI, and SSE. The Fastify API exposes powerful local routes: profile edits, settings and credential writes, workflow starts, resume imports, manual captures, artifact rendering/opening, Apply Review decisions, apply controls, and the GET /v1/events/stream event stream. Important controls are Zod/shared-contract validation, prepared SQLite statements, bounded DTO fields, the loopback/Host/Origin/Referer/Sec-Fetch gates above, and the worker-readiness gate for worker-backed actions. Because there is no local auth layer, a bypass of locality checks or an accidental remote bind is a full local-API compromise. React escaping, the small MarkdownDocument renderer, safe-link filtering in resume audit parsing, and HTML preview CSPs reduce UI injection risk; security reviews should still treat any externally supplied href, artifact path, or HTML preview path as sensitive.
Browser extension. The extension is a local companion, not a hosted auth surface. It can POST active-page captures and GET a whitelisted autofill profile DTO only after pairing with the local capability token. It has no apply/submit route, and deterministic autofill excludes password and resume content. Main risks are token disclosure to a local process or malicious extension, an overbroad autofill field whitelist, or content-script bugs that fill the wrong field. Those risks should not be confused with remote-account authorization.
Discovery, enrichment, and crawling. Discovery and enrichment process public job sources, ATS APIs, broad-board crawls, page HTML, captured API responses, and discovered posting/apply URLs. The shared politeness gateway honors robots.txt for rendered detail crawls, uses an honest User-Agent, paces requests per host, and enforces run budgets; JobStreaming remains a documented residual because its internal per-board requests cannot be robots-gated. The shared public-HTTP guard rejects loopback, private-network, link-local, reserved, unspecified, multicast, metadata, and non-HTTP(S) destinations. Discovery detail rendering, smart extraction, Playwright enrichment, LinkedIn apply resolution, contact research, and apply launch use the guard at their applicable initial, final, redirect, popup, and subrequest boundaries. New fetch/navigation paths must reuse that guard rather than creating an unreviewed egress exception.
LLM scoring, materials, and outreach. Scoring, employer analysis, interview prep, contact research, outreach drafts, resume tailoring, and cover-letter generation combine untrusted job/contact text with sensitive profile facts. Main impacts are privacy leakage to configured providers and integrity attacks on scores, recommendations, generated materials, or applicant-side outreach drafts. Controls include structured schemas, deterministic requirement/evidence grounding, provenance rows, rendered-text keyword coverage, never-fabricate detectors, structured judge review, adversarial review for high-fit jobs, and fail-closed preservation of the last accepted artifact. Prompt injection that only changes a reviewable score or draft is usually lower severity than injection that bypasses these gates or ships false claims to an employer.
Apply automation. Apply is the highest-risk path because it drives a real browser over an employer form and the owned Gmail adapter can submit a real application. The current apply agent runs as a local Claude subprocess with --no-session-persistence, explicit --allowedTools, explicit --disallowedTools, a filtered environment, and an owned MCP config. The allowlist covers inspection navigation, page snapshots/screenshots, and optional solve_captcha. Generic text/form/key/ select/drag/dialog writes, Gmail verification lookup, type_credential, and upload_artifact are excluded and explicitly denied alongside shell/file tools, raw Gmail send tools, broad mailbox access, raw page-script evaluation, and broad permission bypass. The default inspection MCP configuration also omits Gmail, profile-database, and credential-policy access. Model-driven browser sessions are transport-locked and cannot perform final browser submission. Job-site passwords and CAPTCHA provider keys are not placed in the model prompt; only the owned CAPTCHA tool receives its configured provider key, and the server consumes at most one outbound solve attempt per apply run. Gmail email applications are sent by JobCtrl's owned sender only after exact Apply Review approval, not by an agent mailbox tool. The detailed containment rules are below in Apply-Path Containment.
Secrets, files, and observability. Runtime LLM provider keys use explicit environment variables first, with a process-start macOS Keychain fallback for three supported settings. The fallback never overrides a non-empty environment value and never exposes the stored value through HTTP, logs, or diagnostics. Native Windows and Linux credential-store adapters are planned. The CapSolver key is an env var scoped to the owned solver tool; Gmail token files are local; job-site passwords, if saved, remain local profile data and are not exposed to the page-reading agent. SQLite, generated artifacts, browser profiles, logs, prompts, completions, and worker directories are sensitive. Langfuse/OTel export is opt-in and metadata-only; LLM prompts and completions are excluded. Enrichment spans intentionally avoid raw posting text, resumes, cover letters, and credentials. Security reviews should watch for secret material in logs, traces, release artifacts, HTML previews, generated PDFs, worker stdout, and exception payloads.
Untrusted files and generated previews. Profile import parses user-supplied PDFs, manual capture accepts page text/HTML, and resume review/template flows render generated HTML/PDF previews. These paths should preserve size limits, timeouts, path confinement under ~/.jobctrl/, HTML escaping, CSPs, and executable-markup checks. Bugs here are usually local DoS or file disclosure unless attacker-controlled content can cross into code execution, API control, or secret exfiltration.
Severity Calibration
Critical: untrusted job/web content causes Claude, Playwright, or a worker subprocess to execute arbitrary local commands; read or exfiltrate ~/.jobctrl/, browser cookies, Gmail content, API keys, or environment secrets; submit real applications without a fresh bound approval; or bypass locality so a remote attacker controls the local API. Playwright/HTTP SSRF that reads local or cloud metadata and sends it to a DB, LLM, or telemetry provider is also critical.
High: persistent XSS or unsafe URL rendering in the local UI enables API calls, PII theft, or phishing inside the trusted local app; prompt injection bypasses apply safeguards without shell access but materially changes submitted answers/materials; Gmail connector flaws read broad mailbox content or send without the owned approval path; SQL injection or artifact/path traversal reachable from job-board content reads or writes sensitive local files.
Medium: prompt injection poisons scoring, ranking, employer analysis, interview prep, outreach drafts, or resume candidates that remain reviewable; untrusted PDF/HTML import causes local CPU/memory/storage exhaustion; an operator-configured source or LLM endpoint performs SSRF; overbroad extension autofill exposes more profile fields than intended; telemetry/logging exposes PII after explicit opt-in but without clear warning or redaction.
Low: UI spoofing, malformed display data, broken links, non-sensitive local DoS, or issues requiring the operator/developer to edit local config, fixtures, database rows, or files they already control. Cross-tenant authorization findings are low/out of scope in current local mode because there is no hosted multi-tenant boundary, but they become high or critical once hosted auth and tenant isolation exist.
Apply-Path Containment
Apply is the riskiest surface: it drives a real browser over employer-controlled content, delegates form inspection to an autonomous agent, and retains an owned Gmail submission sink. It is isolated in its own Temporal workflow with tighter retries and layered containment. The use case (domain/apply/use_cases.py), saga (domain/apply/process_manager.py), launcher (apply/launcher.py), browser adapter (apply/chrome.py), and agent adapter (infrastructure/apply/claude_code_cli.py) enforce it; the full stage walkthrough is in the stage walkthrough.
- No model-owned final browser commit. Every model-driven browser session is transport-locked. A live browser-form run returns
trusted_final_submit_requiredbefore prompt rendering or browser launch. The saga rejects an unlocked live browser config before launching, and the agent adapter rejectsdry_run=Falsebefore writing files or spawning a subprocess. This conservative manual boundary remains until a trusted canonical final-form manifest and one-shot mediator exist below the model. - Atomic approval claim. The launcher opens a
BEGIN IMMEDIATEstage-lock transaction and, whileapproval_requiredis on for a live (non-dry-run) claim, refuses to proceed unless the latest recorded decision for the job isapprove_submit. Because the check runs inside the claim transaction, no API or RPC path can bypass it while enabled. Disabling the claim gate does not grant browser-submit authority or bypass the owned email sink's exact recipient/attachment check. Dry-run claims bypass the approval gate because they submit nothing. - Atomic repeat-application decision. The same live claim transaction recomputes relationships from canonical job identity, accepted duplicate links, and confirmed application facts. Exact identities fail closed; a conservative same-employer/equivalent-role relationship requires a reasoned confirmation. The confirmation is fingerprint-bound to the target and prior evidence, then consumed once in that transaction. Direct dispatch, standing polling, stale clients, approval-disabled mode, and concurrent requests cannot skip or reuse it. Pending Gmail suggestions, notes, dry runs, pre-submit failures, and unverified assumptions are deliberately outside the confirmed fact query.
- At-most-once owned submission. The model never records browser submit intent. For an exact-approved email candidate, the saga rechecks the active capability and writes
ApplySubmitIntendedimmediately before invoking the owned Gmail sender. The claim excludes jobs alreadyrunning,succeeded, orneeds_verification. Combined with the per-job workflow ID (apply-{tenant}-{jobKey}+USE_EXISTING) and a live retry policy of exactly one attempt, an owned send is never silently retried into a duplicate. A crash or provider exception after the checkpoint parks the run asneeds_verification; a run with no submit intent can be safely rewound topending. - Browser-layer dry-run guard. In dry-run,
chrome.pyattaches a CDP session that enables theFetchdomain with one run-bound grant for an exact initialGETto the reviewed application URL. It consumes and records that grant, then fails replays,HEAD, path/query changes, redirects, later document navigation, and all other requests withBlockedByClient, alongside aPage.addScriptToEvaluateOnNewDocumentform-submit guard. Missing allowed navigation evidence downgrades coverage, so dry-run safety and receipts do not depend on the agent choosing not to click submit or narrate success. - Approval-origin browser capability.
BrowserWorkerConfigcarries the reviewed application URL intochrome.py. The browser-level CDP guard canonicalizes its HTTP(S) origin and fails every intercepted page-target request to another origin, including public redirects, popups, tabs, subresources, and live form submissions. Chrome worker targets do not exposeFetch.enable, so dedicated, shared, and service workers are closed whilewaitForDebuggerOnStartstill prevents their code from running. Public routability remains a separate prerequisite; a public destination is not automatically authorized. Cross-origin ATS transitions require a newly reviewed destination rather than learning authority from the page or model. - Spend ceiling as a blast-radius control. The
check_spend_budgetpreflight runs before the apply activity, so a runaway or injected loop cannot spend past the daily ceiling. - Prompt-injection surface. The agent is a Claude apply-runtime subprocess reading untrusted third-party page text, so prompt injection is a genuine exposure. Transport locking removes generic final-browser-submit authority; the remaining controls bound other consequences but do not make page content trusted. The subprocess runs with
--no-session-persistence, an explicit--allowedToolssurface, explicit--disallowedTools, and a filtered environment. The allowlist is limited to inspection navigation, snapshots/screenshots, and optional CAPTCHA solving. Generic browser writes, Gmail verification lookup, credential typing, and artifact upload are explicitly denied and cannot be restored by a caller-supplied MCP configuration. Job-site passwords and CAPTCHA provider keys stay out of the model prompt. The localsolve_captchatool owns provider-key use when configured and enforces one outbound provider attempt per apply run even when that attempt fails. Gmail send is not exposed as an agent tool; email-only applications are recorded as review candidates and sent only by the owned email sender after a matching Apply Review approval.
The product-level no-bypass rule (BR-001) is the policy behind these mechanisms: JobCtrl must never bypass CAPTCHA, paywall, login, rate-limit, or bot-control gates without explicit user authorization.
Repeat decisions use dedicated append-only audit rows instead of changing historical application facts. Assessment rows capture the relationship evidence that produced a warning or block; override rows capture actor, reason, timestamp, target, selected prior job, and evidence fingerprint; consumption rows bind the one-attempt authorization to its run. API reads expose the bounded evidence and audit trail without projecting notes, mail bodies, job descriptions, or other private payloads.
Truthfulness And Integrity Gates
Resume tailoring has deterministic controls that are security-adjacent because they prevent the product from emitting false claims to an employer. A never-fabricate detector hard-rejects any numeric, date, title, or employer token that does not trace to recorded profile evidence; a prose skill/tool gate rejects invented named technologies; claim grounding binds every coverage-bearing claim to shipped rendered text; and a structured judge (plus adversarial personas on high-fit jobs) must pass before approval. The same never-fabricate and skill/tool gates run over the cover-letter body before it can ship. Generator retries map outcomes to bounded code-owned reason guidance; free-form validator, judge, adversarial, and prior-output text remains audit-only and never enters a later generator message. These gates fail closed — when no clean candidate survives, the resume is not approved and the last accepted artifact is preserved. Full detail is in Resume Tailoring.
Secrets And Data Hygiene
Never commit local secrets or generated user data: .env files or API keys, jobctrl.db or any copied SQLite database, resumes, cover letters, PDFs, screenshots with real profile data, browser profiles, Gmail OAuth tokens, apply-worker state, or raw logs and traces. Use synthetic fixtures or pnpm qa:seed for reproduction cases. This mirrors the rules in SECURITY.md and .gitignore.
Store credentials in a secret port. Credentials must use a secret port or explicit environment variables, never SQLite, snapshots, logs, traces, or artifacts (TR-013). The macOS-only API store (apps/api/src/credentials.ts) accepts only the fixed Claude/Google guided allowlist plus legacy OpenAI-key deletion. It can atomically replace a provider configuration, presence-check, and remove those Keychain entries without returning values. Private reads are limited to compensating rollback after a failed batch and must never be logged or sent over HTTP. Presence is tri-state: configured: false means confirmed absent; configured: null with inspection_failed means unknown and must not be collapsed into absence. Unsupported mutations return a sanitized 409, operational store failures a sanitized 503 with an explicit failure reason, and neither exposes raw security output. After env-file loading, the shared Python config.load_env() boundary loads a missing or empty value through a bounded, non-interactive Keychain lookup once per process; any non-empty environment value wins, failures degrade to no fallback, and long-lived workers require a restart after a web edit. Windows and Linux use environment configuration until their planned native adapters ship. The CapSolver key is an env var scoped to the owned CAPTCHA tool. A job-site login password, if the user provides one, remains local profile data; it is neither interpolated into the Apply prompt nor passed to the inspection MCP configuration. The page-reading model has no credential-typing authority, even when JOBCTRL_TRUSTED_JOB_SITE_CREDENTIAL_ORIGINS is configured.
Keep detection separate from browser adoption. The browser-capability list may inspect known installation locations, but the RPC/API response exposes only bounded candidate IDs and labels, never executable paths. Listing must not launch, persist, copy, or enable a browser. The enable request is a strict XOR between one transient detectedBrowserId and one write-only executablePath. The worker resolves detected IDs again inside the mutation transaction; a stale or missing candidate fails closed and must not reuse cached path data. Profile copying remains a second, separately versioned affirmative-consent arm.
The release gate is enforced before release-bound changes land.scripts/release_check.py runs automatically on every push to main and is available as a manual GitHub workflow for maintainer-reviewed branches. Public pull requests do not run heavyweight CI automatically, so maintainers run the manual workflow or local scanner before merging release-bound changes. The scanner checks the git-tracked and untracked tree — plus any built wheel/sdist archives — for:
- private-profile needles (real names, emails, personal domains, employer evidence, home paths, and private toolchain paths);
- non-placeholder secret assignments in
.env, JSON, YAML, and TOML files; - forbidden filenames (
.env*,resume.pdf,resume.txt,profile.json,token.json) and blocked suffixes (.db,.sqlite,.pdf,.log,.pem,.key,.docx,.har, and database sidecars); - browser-profile artifacts and the private
.planning/corpus; - a blocked distribution name combined with a tag-publish trigger.
It also scans for apply-prompt tripwires: CapSolver key interpolation, hardcoded attestation defaults, and profile-password interpolation. The default local mode keeps these as compatibility warnings for investigations, while both the main/manual privacy workflow and the post-build publication gate run --strict-prompt, where any of those tripwires is a failure. Treat a passing release check as necessary but not sufficient, and do not add real profile data to a fixture just because the scrubber is green today.
The docs site has a publish boundary. The VitePress config (docs/.vitepress/config.ts) excludes docs/plans/, docs/incidents/, docs/backlog.md, docs/delivered.md, and the repo-facing docs/README.md from the built site via srcExclude, and rewrites any inbound link that escapes the published set (repo-root files or unpublished internal docs) to an absolute GitHub URL so the deployed site never ships a relative link that 404s. When adding a page, keep internal-only material in the excluded set and link to it normally; the config handles the rewrite.
Hosted-Future Posture
The local-only posture is a deliberate stop on the way to a hosted multi-tenant target, and the seams that would change security are already named in docs/architecture/domain-model/cloud.md §9 (with fitness functions in §9.4) and the SaaS section of the backlog. The load-bearing ones:
- API authentication. "No auth" holds only while the API is loopback-bound. Any public-facing deployment triggers an Identity & Access context — Auth0 or Cognito issuing JWTs, validated by a gateway that injects a
TenantContext { tenantId, userId, roles }into every request. - Tenant derivation. Domain types already carry
TenantId; today it is the constantlocal. In hosted mode the value's source changes to JWT claims — a mechanical change, because query keys, events, and projections are already tenant-scoped. - Secret storage. The current environment-first, macOS-Keychain-fallback credential model gives way to a managed secret vault (e.g. AWS Secrets Manager) on any hosted or multi-tenant deployment.
.envis unencrypted. - Browser isolation. Local Chrome on CDP ports becomes managed browser sessions (e.g. Browserbase) on any cloud deployment, because running Chrome in a container needs elevated privileges or
--no-sandbox. This is a day-1 cloud blocker, not a gradual migration.
None of these exist in local mode today; they are the next-evolution seam, and each is gated by a concrete trigger rather than shipped speculatively.
Reporting A Security Issue
Report vulnerabilities privately and never in a public issue with exploit details. Prefer GitHub private vulnerability reporting when enabled; otherwise open a minimal public issue asking for a private contact path, omitting secrets, logs, profile data, generated materials, and local paths. The policy is SECURITY.md.