moonlighter-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .venv/
5
+ *.egg-info/
6
+ .superpowers/
7
+ .claude/
8
+ .claude.local.md
9
+
10
+ # Personal data — kept on disk locally, out of the repo. Generic templates
11
+ # (.example) get added when preparing the public release.
12
+ config.yaml
13
+ profile/
14
+ docs/
15
+ specs/
16
+ company_list.yaml
17
+ blocklist_learned.yaml
18
+ TODO.md
19
+ *.db
20
+
21
+ # Coverage
22
+ .coverage
23
+ .coverage.*
24
+ htmlcov/
25
+ coverage.xml
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.5
2
+ Name: moonlighter-core
3
+ Version: 0.1.0
4
+ Summary: Core database, configuration, and LLM primitives for moonlighter, with an optional browser driver (the [browser] extra)
5
+ Project-URL: Homepage, https://github.com/albertosca/moonlighter
6
+ Project-URL: Repository, https://github.com/albertosca/moonlighter
7
+ Project-URL: Bug Tracker, https://github.com/albertosca/moonlighter/issues
8
+ Author-email: Alberto de Sá Cavalcanti de Albuquerque <albertoalbuquerque01@gmail.com>
9
+ License: AGPL-3.0-only
10
+ Requires-Python: >=3.14
11
+ Requires-Dist: anthropic>=0.40
12
+ Requires-Dist: peewee>=3.17
13
+ Requires-Dist: pyyaml>=6.0
14
+ Requires-Dist: rich>=13.7
15
+ Provides-Extra: browser
16
+ Requires-Dist: playwright>=1.47; extra == 'browser'
File without changes
@@ -0,0 +1,214 @@
1
+ import asyncio
2
+ import contextlib
3
+ import subprocess
4
+ import urllib.request
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from moonlighter.core.config import browser_executable
9
+ from moonlighter.core.log import get_logger
10
+ from playwright.async_api import Browser, BrowserContext, Page, async_playwright
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ _playwright: Any = None
15
+ _browser: Browser | None = None
16
+ _browser_process: subprocess.Popen[bytes] | None = None
17
+
18
+ _DEVTOOLS_PORT_FILE = "DevToolsActivePort"
19
+
20
+
21
+ def _read_devtools_port(session_dir: Path) -> int | None:
22
+ """Read the port Chromium chose for --remote-debugging-port=0 from the
23
+ DevToolsActivePort file it writes inside OUR OWN user-data-dir (S-03).
24
+ None if the file doesn't exist yet or is malformed (browser still starting
25
+ up) — we never trust a fixed port nor "whatever answers" on a known
26
+ port."""
27
+ port_file = session_dir / _DEVTOOLS_PORT_FILE
28
+ if not port_file.exists():
29
+ return None
30
+ try:
31
+ first_line = port_file.read_text().splitlines()[0]
32
+ return int(first_line)
33
+ except IndexError, ValueError:
34
+ return None
35
+
36
+
37
+ def _devtools_ready(port: int) -> bool:
38
+ try:
39
+ urllib.request.urlopen(f"http://localhost:{port}/json/version", timeout=1)
40
+ return True
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ async def _first_or_new_context(browser: Browser) -> BrowserContext:
46
+ return browser.contexts[0] if browser.contexts else await browser.new_context()
47
+
48
+
49
+ async def _launch_browser(config: dict[str, Any], session_dir: Path) -> int:
50
+ """Launch the browser (Chrome/Chromium/Brave) on a RANDOM debug port
51
+ chosen by the OS itself (--remote-debugging-port=0), and return the real
52
+ port, read from DevToolsActivePort inside OUR OWN user-data-dir (S-03) —
53
+ never a fixed port, and never "whatever answers": the port comes from a
54
+ file that only the process we just launched writes."""
55
+ global _browser_process
56
+ port_file = session_dir / _DEVTOOLS_PORT_FILE
57
+ port_file.unlink(missing_ok=True) # discard the port from a previous dead session
58
+
59
+ logger.info("Launching browser (random debug port)")
60
+ # S603: browser_executable(config) is the operator's local YAML config, which the
61
+ # trust model treats as trusted input -- never reachable from scraped job text, ATS
62
+ # DOM, or email bodies. See specs/2026-07-09-security-audit-findings.md ("Trust model").
63
+ _browser_process = subprocess.Popen( # noqa: S603
64
+ [
65
+ browser_executable(config),
66
+ "--remote-debugging-port=0",
67
+ f"--user-data-dir={session_dir}",
68
+ "--no-first-run",
69
+ ],
70
+ stdout=subprocess.DEVNULL,
71
+ stderr=subprocess.DEVNULL,
72
+ )
73
+ for _ in range(60):
74
+ port = _read_devtools_port(session_dir)
75
+ if port is not None and _devtools_ready(port):
76
+ return port
77
+ await asyncio.sleep(0.5)
78
+ _browser_process.kill()
79
+ _browser_process = None
80
+ raise RuntimeError("Browser did not become available (DevToolsActivePort) within 30s")
81
+
82
+
83
+ async def get_context(config: dict[str, Any]) -> BrowserContext:
84
+ """Return a browser context via CDP. Launches the browser if not running."""
85
+ global _playwright, _browser
86
+
87
+ if _browser is not None and _browser.is_connected():
88
+ return await _first_or_new_context(_browser)
89
+
90
+ session_dir = Path(config["browser_session_dir"]).expanduser()
91
+ session_dir.mkdir(parents=True, exist_ok=True)
92
+ port = _read_devtools_port(session_dir)
93
+ if port is None or not _devtools_ready(port):
94
+ port = await _launch_browser(config, session_dir)
95
+
96
+ _playwright = await async_playwright().start()
97
+ _browser = await _playwright.chromium.connect_over_cdp(
98
+ f"http://localhost:{port}",
99
+ slow_mo=config.get("slow_mo_ms", 300),
100
+ )
101
+ logger.info("CDP connected")
102
+ return await _first_or_new_context(_browser)
103
+
104
+
105
+ async def new_page(config: dict[str, Any]) -> Page:
106
+ context = await get_context(config)
107
+ page = await context.new_page()
108
+ logger.debug("new_page created")
109
+ return page
110
+
111
+
112
+ async def save_screenshot(page: Page, job_id: int, step: str, config: dict[str, Any]) -> str:
113
+ """Capture the page, restoring a minimized window for the duration.
114
+
115
+ `page.screenshot()` captures from the compositor surface, and a minimized
116
+ window produces no new frames — the call then blocks until it times out.
117
+ The browser-driven filler this predates minimized the window before
118
+ filling, which made the 03-filled review artifact impossible to produce,
119
+ on every ATS. Reproduced on both Greenhouse and Recruitee; `fromSurface:
120
+ False` was measured as an alternative and took 175s, so restoring around
121
+ the capture it is. Kept for browser-based extensions (e.g. LinkedIn
122
+ scanning) that still drive a page — the assisted flow that replaced the
123
+ in-repo filler never opens a browser.
124
+
125
+ The window is put back exactly as it was, so the two screenshots taken with
126
+ the window deliberately visible are not minimized as a side effect. Every
127
+ window-state call is best-effort (as elsewhere in this module): losing the
128
+ minimized posture must never cost us the screenshot.
129
+ """
130
+ screenshots_dir = Path(config["screenshots_dir"]) / str(job_id)
131
+ screenshots_dir.mkdir(parents=True, exist_ok=True)
132
+ path = str(screenshots_dir / f"{step}.png")
133
+
134
+ cdp = None
135
+ window_id = None
136
+ with contextlib.suppress(Exception):
137
+ cdp = await page.context.new_cdp_session(page)
138
+ info = await cdp.send("Browser.getWindowForTarget")
139
+ if info.get("bounds", {}).get("windowState") == "minimized":
140
+ window_id = info["windowId"]
141
+ await cdp.send(
142
+ "Browser.setWindowBounds",
143
+ {"windowId": window_id, "bounds": {"windowState": "normal"}},
144
+ )
145
+ try:
146
+ # full_page, because this is a review artifact: the viewport is ~750 CSS
147
+ # px and a real application form runs to ~4500, so a viewport capture
148
+ # showed 17% of it — asking a human to approve what they cannot see.
149
+ await page.screenshot(path=path, full_page=True)
150
+ finally:
151
+ if cdp is not None and window_id is not None:
152
+ with contextlib.suppress(Exception):
153
+ await cdp.send(
154
+ "Browser.setWindowBounds",
155
+ {"windowId": window_id, "bounds": {"windowState": "minimized"}},
156
+ )
157
+ return path
158
+
159
+
160
+ async def _set_window_state(page: Page, window_state: str) -> None:
161
+ cdp = await page.context.new_cdp_session(page)
162
+ target_info = await cdp.send("Browser.getWindowForTarget")
163
+ await cdp.send(
164
+ "Browser.setWindowBounds",
165
+ {"windowId": target_info["windowId"], "bounds": {"windowState": window_state}},
166
+ )
167
+
168
+
169
+ async def hide_window(page: Page) -> None:
170
+ """Minimize the browser window via CDP. Idempotent."""
171
+ await _set_window_state(page, "minimized")
172
+
173
+
174
+ async def show_window(page: Page) -> None:
175
+ """Restore and focus the browser window via CDP. Idempotent."""
176
+ await _set_window_state(page, "normal")
177
+
178
+
179
+ async def detach() -> None:
180
+ """Let go of the browser completely, leaving it running for the human.
181
+
182
+ Unlike close(), the browser PROCESS survives — the window, its tabs and a
183
+ filled-in form stay exactly as they are. Only the Playwright driver goes
184
+ away, so the page stops being automation-controlled.
185
+
186
+ This exists for captcha: a token minted inside a CDP-controlled tab does not
187
+ validate server-side (Recruitee answers HTTP 422 on captchaToken), so the
188
+ only honest handover is to actually stop driving. Errors on the way out are
189
+ swallowed, but the handles are cleared regardless — a stale connection would
190
+ be reused by the next call.
191
+ """
192
+ global _playwright, _browser
193
+ if _browser:
194
+ with contextlib.suppress(Exception):
195
+ await _browser.close()
196
+ _browser = None
197
+ if _playwright:
198
+ with contextlib.suppress(Exception):
199
+ await _playwright.stop()
200
+ _playwright = None
201
+ logger.info("browser: detached — no longer automation-controlled")
202
+
203
+
204
+ async def close() -> None:
205
+ global _playwright, _browser, _browser_process
206
+ if _browser:
207
+ await _browser.close()
208
+ _browser = None
209
+ if _playwright:
210
+ await _playwright.stop()
211
+ _playwright = None
212
+ if _browser_process:
213
+ _browser_process.terminate()
214
+ _browser_process = None
@@ -0,0 +1,368 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import yaml
6
+
7
+
8
+ def moonlighter_home() -> Path:
9
+ return Path(os.environ.get("MOONLIGHTER_HOME", "~/.moonlighter")).expanduser()
10
+
11
+
12
+ def resolve_under_home(value: str) -> Path:
13
+ """A config-supplied path, resolved under MOONLIGHTER_HOME when it is relative.
14
+
15
+ Same convention as cv.default (see application/answers/cv.py's
16
+ configured_cv_path): an absolute path, or a '~'-prefixed one, is honored
17
+ exactly as given; anything else is joined onto moonlighter_home(). Callers
18
+ are expected to reject an empty string themselves with a message naming the
19
+ config key -- Path("").expanduser() is ".", a directory that always exists,
20
+ so resolving it silently here would trade a clear "not configured" error for
21
+ a confusing "Is a directory" failure downstream.
22
+ """
23
+ path = Path(value).expanduser()
24
+ if not path.is_absolute():
25
+ path = moonlighter_home() / path
26
+ return path
27
+
28
+
29
+ def _learned_blocklist_path() -> Path:
30
+ return moonlighter_home() / "blocklist_learned.yaml"
31
+
32
+
33
+ def browser_executable(config: dict[str, Any]) -> str:
34
+ """Browser executable path. Reads 'browser_path'; falls back to the legacy
35
+ 'brave_path' key when browser_path is empty."""
36
+ path: str = config.get("browser_path") or config.get("brave_path", "")
37
+ return path
38
+
39
+
40
+ # Sentinel for a form field the LLM did not (or should not) answer, stopping in front
41
+ # of the operator instead of guessing. This is a constant, not a config key: every
42
+ # producer (base.py, work_auth.py) and every consumer (service.py's submission gate,
43
+ # greenhouse.py's skip list) must agree on the exact same string, or an unanswered
44
+ # field silently degrades into whatever literal text was configured — typed into a
45
+ # real form field and submitted, with no operator stop. There is no way to make that
46
+ # divergence safe by configuration; the fix is for the string to have exactly one
47
+ # source of truth.
48
+ NEEDS_REVIEW_SENTINEL = "__NEEDS_REVIEW__"
49
+
50
+
51
+ DEFAULTS: dict[str, Any] = {
52
+ # Browser executable path (Chrome/Chromium/Brave). Empty by default:
53
+ # set browser_path in config.yaml. Accepts brave_path (legacy) as a fallback.
54
+ "browser_path": "",
55
+ # Which LLM backend runs evaluations and answer generation.
56
+ # "cli" -> the `claude` CLI, using the claude.ai subscription. No API key.
57
+ # "api" -> the Anthropic SDK. Requires ANTHROPIC_API_KEY.
58
+ # Default is "cli" because that is what `moonlighter init`, the README, and
59
+ # config.example.yaml all lead with -- an installer coming through
60
+ # `uvx moonlighter` has Claude Code far more often than an API key.
61
+ "llm_backend": "cli",
62
+ "score_threshold": 6.5,
63
+ "llm_model": "claude-sonnet-4-6",
64
+ "eval_model": "claude-haiku-4-5-20251001",
65
+ "slow_mo_ms": 300,
66
+ "title_blocklist": [],
67
+ # Max parallel LLM evaluations in the scan. Bounds the token burst and the
68
+ # waste after the spend-limit (in-flight siblings). With batching (scan_batch_size),
69
+ # this is the number of BATCHES in parallel. Effective concurrency = scan_concurrency × scan_batch_size.
70
+ "scan_concurrency": 5,
71
+ # Jobs evaluated per LLM call. The profile is sent once per batch, cutting
72
+ # re-transmission by a factor of K. 1 disables batching (1 job per call).
73
+ "scan_batch_size": 5,
74
+ # CV per company. Paths relative to MOONLIGHTER_HOME; case-insensitive match.
75
+ # 'default' used when the company has no entry, and defaults to 'cv.pdf'
76
+ # (i.e. MOONLIGHTER_HOME/cv.pdf) — the same file the startup warning names.
77
+ # Can be overridden in the local config.yaml. If the chosen file doesn't
78
+ # exist, the composer emits a gap on the file-upload question instead of
79
+ # naming a file to attach.
80
+ "cv": {
81
+ "default": "cv.pdf",
82
+ "by_company": {},
83
+ },
84
+ # Country-dependent work authorization. The candidate is authorized to work
85
+ # only in their citizenship country. When the job's country cannot be
86
+ # confidently inferred, the field becomes __NEEDS_REVIEW__ (manual decision — never a guess).
87
+ "work_authorization": {
88
+ "citizenship_country": "",
89
+ "authorized_answer": "Yes",
90
+ "not_authorized_answer": "No",
91
+ },
92
+ # Gmail response tracking. Relative filenames, resolved under MOONLIGHTER_HOME
93
+ # by resolve_under_home() at the point of use (same convention as cv.default) —
94
+ # NOT hardcoded to ~/.moonlighter, which ignores a MOONLIGHTER_HOME override.
95
+ # setup_email() creates the token after the OAuth consent. NOTE: setup_email()
96
+ # writes (and overwrites) whatever file token_path names — point it at an
97
+ # absolute path elsewhere only if you own that file.
98
+ "email": {
99
+ "credentials_path": "gmail-client.json",
100
+ "token_path": "gmail-token.json",
101
+ },
102
+ }
103
+
104
+ _PATH_KEYS = ("browser_session_dir", "screenshots_dir")
105
+
106
+
107
+ class ConfigError(Exception):
108
+ """Raised when config.yaml has an unknown key or a value of the wrong type."""
109
+
110
+
111
+ # (type, ...) — a tuple of acceptable types; bool is excluded from int keys explicitly.
112
+ _INT = (int,)
113
+ _NUM = (int, float)
114
+
115
+ # Top-level key -> acceptable types. Nested dict blocks use a sub-schema below.
116
+ _CONFIG_SCHEMA: dict[str, tuple[type, ...]] = {
117
+ "browser_path": (str,),
118
+ "brave_path": (str,),
119
+ "browser_session_dir": (str,),
120
+ "screenshots_dir": (str,),
121
+ "score_threshold": _NUM,
122
+ "slow_mo_ms": _INT,
123
+ "scan_concurrency": _INT,
124
+ "scan_batch_size": _INT,
125
+ "llm_model": (str,),
126
+ "eval_model": (str,),
127
+ "llm_backend": (str,),
128
+ "title_blocklist": (list,),
129
+ "cv": (dict,),
130
+ "work_authorization": (dict,),
131
+ "email": (dict,),
132
+ "scan_gupy": (bool,),
133
+ "scan_remoteok": (bool,),
134
+ "scan_remotive": (bool,),
135
+ "scan_wwr": (bool,),
136
+ "scan_hn_whoishiring": (bool,),
137
+ }
138
+
139
+ _CV_SCHEMA: dict[str, tuple[type, ...]] = {"default": (str,), "by_company": (dict,)}
140
+ _WORK_AUTH_SCHEMA: dict[str, tuple[type, ...]] = {
141
+ "citizenship_country": (str,),
142
+ "authorized_answer": (str,),
143
+ "not_authorized_answer": (str,),
144
+ }
145
+ _EMAIL_SCHEMA: dict[str, tuple[type, ...]] = {
146
+ "address": (str,),
147
+ "credentials_path": (str,),
148
+ "token_path": (str,),
149
+ "processed_label": (str,),
150
+ "mark_processed": (bool,),
151
+ "lookback_days": (int,),
152
+ "interview_stages": (list,),
153
+ }
154
+ _NESTED_SCHEMAS = {
155
+ "cv": _CV_SCHEMA,
156
+ "work_authorization": _WORK_AUTH_SCHEMA,
157
+ "email": _EMAIL_SCHEMA,
158
+ }
159
+
160
+
161
+ def _check_type(key: str, value: Any, types: tuple[type, ...]) -> None:
162
+ # bool is a subclass of int; reject it where int is required (and bool is not listed).
163
+ if isinstance(value, bool) and bool not in types:
164
+ raise ConfigError(
165
+ f"config key '{key}' must be {', '.join(t.__name__ for t in types)}, got bool"
166
+ )
167
+ if not isinstance(value, types):
168
+ raise ConfigError(
169
+ f"config key '{key}' must be {', '.join(t.__name__ for t in types)}, "
170
+ f"got {type(value).__name__}"
171
+ )
172
+
173
+
174
+ LLM_BACKENDS = ("cli", "api")
175
+
176
+
177
+ def llm_backend(config: dict[str, Any]) -> str:
178
+ """The configured LLM backend, validated.
179
+
180
+ Single source of truth for every site that branches on the backend -- the
181
+ caller factory and the startup checks -- so a warning can never describe a
182
+ different backend from the one that will actually run. Raises ConfigError
183
+ on anything outside LLM_BACKENDS: an unrecognized value used to fall
184
+ through to 'api' in silence, which turned the typo 'CLI' into a demand for
185
+ an API key the user had no reason to own.
186
+ """
187
+ backend: str = config.get("llm_backend", DEFAULTS["llm_backend"])
188
+ if backend not in LLM_BACKENDS:
189
+ raise ConfigError(
190
+ f"config key 'llm_backend' must be one of {', '.join(LLM_BACKENDS)}, got {backend!r}"
191
+ )
192
+ return backend
193
+
194
+
195
+ def validate_config(config: dict[str, Any]) -> None:
196
+ """Strict, closed-schema validation. Raises ConfigError on the first unknown key or
197
+ wrong-typed value (naming the key). Runs after the DEFAULTS merge, so an omitted key is
198
+ filled by defaults and never fails here — only wrong types and unknown extras fail."""
199
+ for key, value in config.items():
200
+ if key not in _CONFIG_SCHEMA:
201
+ raise ConfigError(f"unknown config key '{key}'")
202
+ _check_type(key, value, _CONFIG_SCHEMA[key])
203
+ if key == "llm_backend":
204
+ llm_backend(config)
205
+ sub_schema = _NESTED_SCHEMAS.get(key)
206
+ if sub_schema is not None:
207
+ for sub_key, sub_value in value.items():
208
+ if sub_key not in sub_schema:
209
+ raise ConfigError(f"unknown config key '{key}.{sub_key}'")
210
+ _check_type(f"{key}.{sub_key}", sub_value, sub_schema[sub_key])
211
+ if key == "email" and sub_key == "lookback_days" and sub_value <= 0:
212
+ # Gmail's newer_than:0d matches zero messages (verified live) —
213
+ # a non-positive value silently disables the sync forever, reading
214
+ # exactly like an empty mailbox instead of a config mistake.
215
+ raise ConfigError(
216
+ f"config key 'email.lookback_days' must be positive, got {sub_value}"
217
+ )
218
+
219
+
220
+ def load_config(config_path: str | Path | None = None) -> dict[str, Any]:
221
+ """
222
+ Load configuration from YAML file, merging with defaults.
223
+
224
+ Expands home directory (~) paths for designated path keys.
225
+
226
+ Args:
227
+ config_path: Path to config.yaml file
228
+
229
+ Returns:
230
+ dict with merged config (defaults + overrides)
231
+ """
232
+ config_path = (
233
+ Path(config_path) if config_path is not None else moonlighter_home() / "config.yaml"
234
+ )
235
+ home = moonlighter_home()
236
+ config: dict[str, Any] = {
237
+ **DEFAULTS,
238
+ "browser_session_dir": str(home / "browser-session"),
239
+ "screenshots_dir": str(home / "screenshots"),
240
+ }
241
+ if config_path.exists():
242
+ user = yaml.safe_load(config_path.read_text()) or {}
243
+ config.update(user)
244
+ for key in _PATH_KEYS:
245
+ config[key] = str(Path(config[key]).expanduser())
246
+
247
+ # Merge learned blocklist (blocklist_learned.yaml) into title_blocklist
248
+ learned = _learned_blocklist_path()
249
+ if learned.exists():
250
+ data = yaml.safe_load(learned.read_text()) or {}
251
+ learned_patterns = data.get("title_blocklist", [])
252
+ if learned_patterns:
253
+ manual = config.get("title_blocklist", [])
254
+ merged = list(dict.fromkeys(manual + learned_patterns)) # dedup, manual first
255
+ config["title_blocklist"] = merged
256
+
257
+ return config
258
+
259
+
260
+ def load_profile(profile_path: str | Path | None = None) -> dict[str, Any]:
261
+ """
262
+ Load profile from YAML file.
263
+
264
+ Args:
265
+ profile_path: Path to profile.yaml file
266
+
267
+ Returns:
268
+ dict with profile data (skills, experience, preferences, criteria, etc.)
269
+ """
270
+ profile_path = (
271
+ Path(profile_path) if profile_path is not None else moonlighter_home() / "profile.yaml"
272
+ )
273
+ return yaml.safe_load(profile_path.read_text()) or {}
274
+
275
+
276
+ def load_company_list(path: str | Path | None = None, phase: str | None = None) -> dict[str, Any]:
277
+ """Load the company list from YAML, optionally filtered by phase.
278
+
279
+ company_list.yaml groups entries by ATS and phase:
280
+ greenhouse:
281
+ phase1: [slug, ...]
282
+ phase2: [slug, ...]
283
+
284
+ An entry is an ATS slug ("nubank") or, for Recruitee, optionally a full
285
+ custom career domain ("jobs.channable.com").
286
+
287
+ Args:
288
+ path: path to company_list.yaml.
289
+ phase: "phase1", "phase2", "phase3", or None for all phases combined.
290
+
291
+ Returns:
292
+ dict: {source: [entry, ...]}
293
+ """
294
+ path = Path(path) if path is not None else moonlighter_home() / "company_list.yaml"
295
+ if not path.exists():
296
+ return {}
297
+ raw = yaml.safe_load(path.read_text()) or {}
298
+
299
+ result = {}
300
+ for source, value in raw.items():
301
+ if isinstance(value, list):
302
+ # Legacy format: flat list without phases
303
+ result[source] = value
304
+ elif isinstance(value, dict):
305
+ if phase:
306
+ result[source] = value.get(phase, [])
307
+ else:
308
+ # All phases concatenated
309
+ slugs = []
310
+ for slugs_in_phase in value.values():
311
+ if isinstance(slugs_in_phase, list):
312
+ slugs.extend(slugs_in_phase)
313
+ result[source] = slugs
314
+ else:
315
+ result[source] = []
316
+
317
+ for source, entries in result.items():
318
+ if not isinstance(entries, list):
319
+ # A phase filter selecting a non-list value (e.g. a scalar phase
320
+ # entry) leaves `entries` as that raw value -- a string iterates
321
+ # character-by-character, and every single-char "slug" is a str,
322
+ # so the entry-level check below would silently pass.
323
+ raise ConfigError(
324
+ f"company_list.yaml: source {source!r} did not resolve to a list "
325
+ f"(got {type(entries).__name__}: {entries!r})"
326
+ )
327
+ for entry in entries:
328
+ if not isinstance(entry, str):
329
+ raise ConfigError(
330
+ f"company_list.yaml: source '{source}' has a non-string entry: {entry!r}"
331
+ )
332
+
333
+ return result
334
+
335
+
336
+ _HARDEN_FILES = (
337
+ "moonlighter.db",
338
+ "profile.yaml",
339
+ "config.yaml",
340
+ "app.log",
341
+ "blocklist_learned.yaml",
342
+ )
343
+ _HARDEN_DIRS = ("browser-session", "screenshots")
344
+
345
+
346
+ def harden_permissions() -> list[str]:
347
+ """Set 0600/0700 on the sensitive files/subdirectories under ~/.moonlighter
348
+ (S-07): moonlighter.db, profile.yaml and config.yaml carry full PII;
349
+ browser-session/ holds cookies equivalent to LinkedIn credentials.
350
+ Best-effort and never raises — a permission error becomes a warning,
351
+ since the server must stay up even on an unusual filesystem/ACL setup."""
352
+ home = moonlighter_home()
353
+ warnings: list[str] = []
354
+ for name in _HARDEN_FILES:
355
+ path = home / name
356
+ if path.exists():
357
+ try:
358
+ path.chmod(0o600)
359
+ except OSError as e:
360
+ warnings.append(f"could not restrict permissions on {path}: {e}")
361
+ for name in _HARDEN_DIRS:
362
+ path = home / name
363
+ if path.exists():
364
+ try:
365
+ path.chmod(0o700)
366
+ except OSError as e:
367
+ warnings.append(f"could not restrict permissions on {path}: {e}")
368
+ return warnings