arkaos 4.46.0 → 4.48.0

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.
Files changed (44) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/core/egress/policy.py +84 -32
  4. package/core/harness/cli.py +143 -0
  5. package/core/harness/drift.py +160 -9
  6. package/core/harness/manager.py +577 -0
  7. package/core/harness/manifest.py +5 -1
  8. package/core/kb/__init__.py +9 -0
  9. package/core/kb/nlm_client.py +883 -0
  10. package/harness/codex/AGENTS.md +1 -1
  11. package/harness/copilot/copilot-instructions.md +1 -1
  12. package/harness/cursor/rules/arkaos.mdc +2 -2
  13. package/harness/gemini/GEMINI.md +1 -1
  14. package/harness/opencode/AGENTS.md +1 -1
  15. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  16. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  17. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  18. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  19. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  20. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  21. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  22. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  23. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  24. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  25. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  26. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  27. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  28. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  29. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  30. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  31. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  32. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  33. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  34. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  35. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  36. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  37. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  38. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  39. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  40. package/harness/zed/.rules +1 -1
  41. package/installer/cli.js +27 -0
  42. package/knowledge/skills-manifest.json +1 -1
  43. package/package.json +1 -1
  44. package/pyproject.toml +1 -1
@@ -0,0 +1,883 @@
1
+ """NotebookLM chokepoint — the one door, and the guard stands at it.
2
+
3
+ D1 shipped ``core.egress`` with zero call sites. This module is the
4
+ first, and for NotebookLM the only one: every payload passes
5
+ ``egress.policy.evaluate`` BEFORE a subprocess is constructed, so a
6
+ denied payload never reaches argv, an environment variable, a temp
7
+ file, or the CLI. What leaves is the redacted text the guard returned,
8
+ never the caller's original.
9
+
10
+ ``evaluate`` rather than ``enforce`` — deliberately. ``enforce`` raises
11
+ :class:`~core.egress.policy.EgressDeniedError` and hands back only the
12
+ cleared text; this module needs the decision, because the telemetry
13
+ line carries both digests and the finding kinds. ``evaluate`` is
14
+ documented never to raise; the call is wrapped anyway, and a guard
15
+ that fails is a denial (``egress guard error: <type>``), never a send.
16
+
17
+ The tool is not vendored. ``notebooklm-py`` drives undocumented Google
18
+ endpoints and is a moving target, so the operator installs it (``uv
19
+ tool install``) and this module shells out to whatever is on PATH — or
20
+ reports its absence. Nothing here installs anything.
21
+
22
+ Degradation is a contract. Tool absent, timeout, nonzero exit,
23
+ rejected option, unusable or refused home — every one returns a
24
+ :class:`NotebookLMResult` carrying an ``[arka:source-skipped]``
25
+ marker, so the caller falls back to ``arka-research`` with the gap
26
+ declared. :func:`send` and :func:`check` never raise: every failure,
27
+ including a hostile argument, comes back as a result. The path
28
+ helpers below them take a ``Path`` at their word.
29
+
30
+ ``QG D2 r<N>, <Reviewer> <ID>`` citations below record why a
31
+ non-obvious choice is there. The round numbers are PER REVIEWER —
32
+ Eduardo's r1 ran after Francisca's r4 — so two citations reading r1
33
+ are not the same moment.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import contextlib
39
+ import errno
40
+ import importlib
41
+ import json
42
+ import math
43
+ import os
44
+ import pkgutil
45
+ import re
46
+ import shutil
47
+ import stat
48
+ import subprocess
49
+ import tempfile
50
+ from collections.abc import Callable
51
+ from dataclasses import dataclass, field
52
+ from datetime import UTC, datetime
53
+ from pathlib import Path
54
+ from types import ModuleType
55
+ from typing import Any
56
+
57
+ import core.egress as egress
58
+ from core.egress import policy
59
+
60
+ # The injected process runner. Only ``subprocess.run``'s shape is ever
61
+ # passed; typing it keeps `.returncode` and `.stdout` checked rather
62
+ # than reached on an ``object`` (QG D2 r2, Francisca M9).
63
+ CompletedProcess = subprocess.CompletedProcess[str]
64
+ Runner = Callable[..., CompletedProcess]
65
+
66
+ BINARY = "notebooklm"
67
+ DEFAULT_TIMEOUT = 180
68
+ _MAX_TIMEOUT = 86_400 # one day; beyond this the value is a mistake
69
+
70
+ # argv is the guard's blind spot: anything appended after the guarded
71
+ # --input is a file the policy never saw, and duplicate-option
72
+ # handling in argparse/click/typer is last-wins, so the UNGUARDED file
73
+ # is the one that uploads (QG D2 r1, Francisca B3).
74
+ #
75
+ # There is no argv pass-through. A validated pass-through was tried and
76
+ # was wrong: it screened tokens starting with a dash and let a bare
77
+ # positional straight through, and `add-source` is exactly the kind of
78
+ # subcommand that takes a positional source — so `/etc/passwd` reached
79
+ # argv with the guard reporting ok (QG D2 r2, Francisca B1). Callers
80
+ # now pass typed options and this module renders argv, so an
81
+ # unvalidated string has no path to it by construction.
82
+ ALLOWED_ACTIONS: frozenset[str] = frozenset(
83
+ {"add-source", "ask", "report", "mindmap"}
84
+ )
85
+ ALLOWED_FORMATS: frozenset[str] = frozenset({"markdown", "json", "text"})
86
+ # Notebook names: alphanumeric, dot, underscore, dash. No separator,
87
+ # so no traversal path — but the first character excludes the dot too,
88
+ # because a leading-dot first class let the bare string ".." through
89
+ # (QG D2 r1, Eduardo B7). No leading dash either, or the value itself
90
+ # reads as an option to the CLI's parser (``--notebook --input`` is
91
+ # not the pair it looks like).
92
+ _NOTEBOOK_PATTERN = "[A-Za-z0-9_][A-Za-z0-9._-]{0,63}"
93
+ _NOTEBOOK_RE = re.compile(rf"\A{_NOTEBOOK_PATTERN}\Z")
94
+
95
+
96
+ def notebooklm_home(home: Path | None = None) -> Path:
97
+ """``NOTEBOOKLM_HOME`` or ``~/.arkaos/notebooklm``, at call time.
98
+
99
+ The env value is expanded and required to be absolute. The
100
+ ``home`` ARGUMENT was hardened to ``Path`` while the environment
101
+ — the door operators actually use — took anything: a relative
102
+ value put the 0600 payload under the process's cwd, routinely a
103
+ git working tree, and a literal ``~/nlm`` from a file parsed
104
+ without shell expansion created a directory named ``~`` there
105
+ (QG D2 r5, Francisca B1).
106
+ """
107
+ override = os.environ.get("NOTEBOOKLM_HOME", "").strip()
108
+ if override:
109
+ return _absolute_override(override)
110
+ return _default_payload_dir(home or _own_home())
111
+
112
+
113
+ def _default_payload_dir(home: Path) -> Path:
114
+ """Where the payload directory lives when no env override is set.
115
+
116
+ One source of truth: the screen restated the same literal and the
117
+ two disagreed on the fallback, so a move of the default would have
118
+ left the screen guarding a path nothing uses (QG D2 r11,
119
+ Francisca M4 — the r2 M4 defect, recurring).
120
+ """
121
+ return home / ".arkaos" / "notebooklm"
122
+
123
+
124
+ def _absolute_override(override: str) -> Path:
125
+ """The env value, expanded and required to be absolute."""
126
+ try:
127
+ resolved = Path(override).expanduser()
128
+ except RuntimeError as exc:
129
+ # ``~teammate/x``, ``~+/x``, ``~-/x``: expanduser raises
130
+ # RuntimeError, which check() does not catch, so the fix for
131
+ # the relative-path hole opened a raising one (QG D2 r6,
132
+ # Francisca B1).
133
+ raise ValueError("NOTEBOOKLM_HOME could not be expanded") from exc
134
+ if not resolved.is_absolute():
135
+ raise ValueError("NOTEBOOKLM_HOME must be an absolute path")
136
+ return resolved
137
+
138
+
139
+ def _own_home() -> Path:
140
+ """``Path.home()``, as a refusal rather than a RuntimeError.
141
+
142
+ No HOME and no passwd entry — a container or a CI runner.
143
+ """
144
+ try:
145
+ return Path.home()
146
+ except RuntimeError as exc:
147
+ raise ValueError("home directory could not be determined") from exc
148
+
149
+
150
+ def telemetry_path(home: Path | None = None) -> Path:
151
+ return (
152
+ (home or Path.home())
153
+ / ".arkaos" / "telemetry" / "notebooklm-usage.jsonl"
154
+ )
155
+
156
+
157
+ def redaction_config_path(home: Path | None = None) -> Path:
158
+ """The identifier list ``home`` implies.
159
+
160
+ Delegates to D1: this was a verbatim copy of the very function the
161
+ previous round made public so callers would stop duplicating it,
162
+ which left two sources of truth for one path
163
+ (QG D2 r2, Francisca M4).
164
+ """
165
+ return policy.default_redaction_config_path(home)
166
+
167
+
168
+ @dataclass
169
+ class NotebookLMResult:
170
+ """Outcome of one call.
171
+
172
+ ``ok`` is False whenever the caller must fall back; ``denied`` (the
173
+ guard refused) and ``degraded`` (the run produced no usable
174
+ result) say which.
175
+ Branching on ``degraded`` alone reads a DENIED payload as a success
176
+ path — the field said "must fall back" and only half meant it
177
+ (QG D2 r1, Eduardo B6).
178
+ """
179
+
180
+ action: str
181
+ ok: bool = False
182
+ denied: bool = False
183
+ degraded: bool = False
184
+ reason: str = ""
185
+ finding_kinds: list[str] = field(default_factory=list)
186
+ stdout: str = ""
187
+ # The resolved CLI path, set by check(). Its own field: stdout used
188
+ # to carry it, so one attribute meant "what the tool printed" on
189
+ # one path and "where the tool is" on the other
190
+ # (QG D2 r1, Francisca M9).
191
+ binary: str = ""
192
+ # The original payload's digest — what an operator greps to ask
193
+ # "did this ever leave?". redacted_sha256 is what actually left
194
+ # (QG D2 r1, Francisca M2: one field meant both, so the grep
195
+ # answered False for a payload that DID leave).
196
+ payload_sha256: str = ""
197
+ redacted_sha256: str = ""
198
+
199
+ @property
200
+ def marker(self) -> str:
201
+ """The line a caller puts on record when this result is unusable."""
202
+ return (
203
+ f"[arka:source-skipped] notebooklm ({self.reason})"
204
+ if not self.ok
205
+ else ""
206
+ )
207
+
208
+ def to_telemetry(self) -> dict[str, Any]:
209
+ return {
210
+ "action": self.action,
211
+ "ok": self.ok,
212
+ "denied": self.denied,
213
+ "degraded": self.degraded,
214
+ "reason": self.reason,
215
+ # KINDS only — see _denial for why.
216
+ "finding_kinds": list(self.finding_kinds),
217
+ "payload_sha256": self.payload_sha256,
218
+ "redacted_sha256": self.redacted_sha256,
219
+ }
220
+
221
+
222
+ # The package spec carries its OWN quotes, and the sentence carries
223
+ # none: wrapping the whole command in quotes meant the operator pasted
224
+ # it bare, and in zsh the square brackets are glob syntax — the line
225
+ # died with "no matches found" before uv ever ran (QG D2 r1, Eduardo B12).
226
+ _ABSENT = (
227
+ f"{BINARY} not on PATH; install it with: "
228
+ f"uv tool install 'notebooklm-py[browser]'"
229
+ )
230
+
231
+
232
+ def check(home: Path | None = None) -> NotebookLMResult:
233
+ """Is the CLI usable? Reports, never installs.
234
+
235
+ The standalone probe: it prepares the payload directory to prove it
236
+ is usable. ``send`` deliberately does NOT go through here — it
237
+ resolves the binary itself and prepares the directory only after
238
+ the guard has cleared the payload, so nothing is created on disk
239
+ for a payload that will be denied (QG D2 r2, Francisca M6).
240
+ """
241
+ # check() does not go through _rejected_input, so it screens the
242
+ # same two home failures itself: a non-Path (QG D2 r1 Eduardo B4,
243
+ # narrowed r2 Eduardo B1) and an unresolvable one, which otherwise
244
+ # fell into the NOTEBOOKLM_HOME arm below and sent the operator to
245
+ # inspect a variable they never set (QG D2 r7, Francisca M2).
246
+ rejected = _rejected_home(home)
247
+ if rejected is not None:
248
+ return _degraded("check", rejected)
249
+ binary = shutil.which(BINARY)
250
+ if not binary:
251
+ return _degraded("check", _ABSENT)
252
+ try:
253
+ _prepare_home(home)
254
+ except OSError as exc:
255
+ return _degraded("check", f"payload home unusable: {_why(exc)}")
256
+ except ValueError as exc:
257
+ return _degraded("check", f"payload home rejected: {exc}")
258
+ return NotebookLMResult(action="check", ok=True, binary=binary)
259
+
260
+
261
+ def _rejected_ancestor(target: Path) -> str | None:
262
+ """Refuse a path reached through an attacker-plantable link.
263
+
264
+ Every component, the final one included: the leaf was guarded
265
+ while ``mkdir(parents=True)`` followed everything above it (r9),
266
+ and ``Path.parents`` excludes the node, so a link at ``.arkaos``
267
+ went through (r10). Ownership, not existence — ``/tmp`` is itself
268
+ a link on macOS, so a link owned by the current user or root is
269
+ the operator's own.
270
+ """
271
+ for component in [*reversed(target.parents), target]:
272
+ try:
273
+ info = component.lstat()
274
+ except (FileNotFoundError, NotADirectoryError):
275
+ # Nothing there to follow. Absent components are routine,
276
+ # and a plain file where a directory belongs gets its
277
+ # accurate reason from the mkdir, not from here.
278
+ continue
279
+ except (OSError, ValueError, TypeError) as exc:
280
+ # One lstat, not is_symlink()+lstat, which raised before
281
+ # this handler saw it (r10 M1). ValueError: `lstat` on a
282
+ # NUL byte, which escaped the boundary (r13 B1).
283
+ # TypeError is defensive only (r14, Francisca M3).
284
+ return f"path is not a usable filesystem path: {_why(exc)}"
285
+ if not stat.S_ISLNK(info.st_mode):
286
+ continue
287
+ if info.st_uid not in (os.getuid(), 0):
288
+ return "path traverses a symlink owned by another user"
289
+ return None
290
+
291
+
292
+ def _prepare_home(home: Path | None) -> Path:
293
+ """The payload directory, created 0700 — never through a symlink.
294
+
295
+ Three layers, learned one per round: the ancestors are screened
296
+ (QG D2 r9, Francisca B1), the leaf is refused before mkdir would
297
+ follow it (r1, Francisca M5), and ownership and mode are settled
298
+ on one ``O_NOFOLLOW`` descriptor rather than across separate
299
+ lookups an attacker could swap between (r2, Francisca M5).
300
+ """
301
+ target = notebooklm_home(home)
302
+ traversal = _rejected_ancestor(target)
303
+ if traversal is not None:
304
+ raise ValueError(traversal)
305
+ if target.is_symlink():
306
+ # Before mkdir: a symlink to a path that does not exist yet
307
+ # would otherwise be FOLLOWED and the directory created at the
308
+ # attacker's end of it.
309
+ raise ValueError("path is a symlink")
310
+ target.mkdir(parents=True, exist_ok=True)
311
+ fd = _open_dir_nofollow(target)
312
+ try:
313
+ if os.fstat(fd).st_uid != os.getuid():
314
+ raise ValueError("path is not owned by the current user")
315
+ os.fchmod(fd, 0o700)
316
+ finally:
317
+ os.close(fd)
318
+ return target
319
+
320
+
321
+ def _open_dir_nofollow(target: Path) -> int:
322
+ """A descriptor on *target* that provably did not follow a link."""
323
+ try:
324
+ return os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
325
+ except OSError as exc:
326
+ # mkdir has already succeeded, so the leaf was a directory
327
+ # a moment ago: failing to open it without following a link
328
+ # means the leaf is a symlink now. Linux reports ELOOP here,
329
+ # macOS ENOTDIR.
330
+ if exc.errno in (errno.ELOOP, errno.ENOTDIR):
331
+ raise ValueError("path is a symlink") from exc
332
+ raise
333
+
334
+
335
+ def send(
336
+ text: object,
337
+ action: str = "add-source",
338
+ *,
339
+ home: Path | None = None,
340
+ timeout: int = DEFAULT_TIMEOUT,
341
+ notebook: str | None = None,
342
+ output_format: str | None = None,
343
+ runner: Runner = subprocess.run,
344
+ ) -> NotebookLMResult:
345
+ """Send one payload to NotebookLM, or explain why nothing was sent.
346
+
347
+ The guard runs FIRST: a denial returns before any process
348
+ plumbing exists. ``runner`` is injectable so tests can prove the
349
+ subprocess was never built. ``notebook`` and ``output_format`` are
350
+ rendered into argv by THIS module — no raw pass-through.
351
+ """
352
+ # ONCE, before the try: computing it inside the handler re-entered
353
+ # a screen that can refuse, and the second raise left the public
354
+ # boundary — the r1 Eduardo B5 shape again (r13, Francisca B1).
355
+ trail = _trail_home(home)
356
+ try:
357
+ return _send(text, action, home, timeout, notebook,
358
+ output_format, runner)
359
+ except Exception as exc: # the boundary claim is absolute
360
+ return _record(
361
+ _degraded(_label(action), f"client error: {type(exc).__name__}"),
362
+ trail,
363
+ )
364
+
365
+
366
+ def _label(action: object) -> str:
367
+ """The action name safe to PERSIST.
368
+
369
+ ``reason`` was scrubbed of paths and the caller's ``action`` string
370
+ was not, so a refused call with an action of
371
+ ``/Users/<operator>/clients/<name>/Q3.md`` wrote that path and that
372
+ identifier into the telemetry file — on a call that never left the
373
+ machine (QG D2 r2, Francisca B2). Only a known action is echoed.
374
+
375
+ The isinstance guard is load-bearing, not defensive typing: an
376
+ unhashable action (a list or dict, the natural shape from a
377
+ JSON-driven caller) makes the membership test raise TypeError.
378
+ That happened twice per call — once inside ``_send`` and again in
379
+ ``send``'s own handler — so the second escaped and the public
380
+ boundary raised (QG D2 r1, Eduardo B5).
381
+ """
382
+ if not isinstance(action, str) or action not in ALLOWED_ACTIONS:
383
+ return "invalid-action"
384
+ return action
385
+
386
+
387
+ def _send(
388
+ text: object,
389
+ action: str,
390
+ home: Path | None,
391
+ timeout: int,
392
+ notebook: str | None,
393
+ output_format: str | None,
394
+ runner: Runner,
395
+ ) -> NotebookLMResult:
396
+ label = _label(action)
397
+ trail = _trail_home(home)
398
+ options = _options(
399
+ label, action, home, timeout, notebook, output_format, runner
400
+ )
401
+ if isinstance(options, NotebookLMResult):
402
+ return _record(options, trail)
403
+ binary = shutil.which(BINARY)
404
+ if not binary:
405
+ return _record(_degraded(label, _ABSENT), trail)
406
+ cleared = _cleared_to_send(text, label, home)
407
+ if isinstance(cleared, NotebookLMResult):
408
+ return _record(cleared, trail)
409
+ text_out, digest = cleared
410
+ return _record(
411
+ _run(text_out, digest, label, home, timeout, options, binary, runner),
412
+ trail,
413
+ )
414
+
415
+
416
+ def _options(
417
+ label: str,
418
+ action: object,
419
+ home: object,
420
+ timeout: object,
421
+ notebook: str | None,
422
+ output_format: str | None,
423
+ runner: object,
424
+ ) -> list[str] | NotebookLMResult:
425
+ """The validated option tokens, or the refusal.
426
+
427
+ Every caller-influenced value is matched against a closed set or a
428
+ closed pattern, so nothing reaches argv — or ``subprocess`` —
429
+ unscreened (QG D2 r2, Francisca B1; QG D2 r3, Francisca M7
430
+ added ``timeout``, the one left out of its own thesis).
431
+
432
+ Union return, not a ``(value, sentinel)`` tuple: dropping the
433
+ isinstance check fails typecheck, where dropping a tuple sentinel
434
+ is silent — and this is the argv refusal (QG D2 r3, Francisca M6).
435
+ """
436
+ rejection = _rejected_input(action, home, timeout, notebook,
437
+ output_format, runner)
438
+ if rejection is not None:
439
+ return _degraded(label, rejection)
440
+ options: list[str] = []
441
+ if notebook is not None:
442
+ options += ["--notebook", notebook]
443
+ if output_format is not None:
444
+ options += ["--format", output_format]
445
+ return options
446
+
447
+
448
+ def _trail_home(home: object) -> Path | None:
449
+ """Where the telemetry line goes for a call using *home*.
450
+
451
+ A REFUSED home must not be the trail's destination: recording
452
+ against it re-created the very directory the refusal exists to
453
+ prevent — ``_record`` mkdirs ``home/.arkaos/telemetry`` — so a
454
+ refusal wrote into the cwd it had just declined to use (QG D2 r8,
455
+ found closing Francisca B1).
456
+ """
457
+ try:
458
+ rejected = _rejected_home(home)
459
+ except Exception:
460
+ # TOTAL by construction: `send` resolves the trail BEFORE its
461
+ # try, so a raise here would leave the public boundary — and
462
+ # resolving it inside the handler instead is what let the
463
+ # second raise escape in the first place (QG D2 r13,
464
+ # Francisca B1).
465
+ return None
466
+ if rejected is not None:
467
+ return None
468
+ return home if isinstance(home, Path) else None
469
+
470
+
471
+ def _rejected_input(
472
+ action: object,
473
+ home: object,
474
+ timeout: object,
475
+ notebook: str | None,
476
+ output_format: str | None,
477
+ runner: object,
478
+ ) -> str | None:
479
+ """Why these inputs may not be used, or None.
480
+
481
+ Membership is tested only on strings: an unhashable action or
482
+ format raises TypeError from ``in`` (QG D2 r1, Eduardo B5).
483
+ """
484
+ if not isinstance(action, str) or action not in ALLOWED_ACTIONS:
485
+ return f"action not allowed; known: {sorted(ALLOWED_ACTIONS)}"
486
+ rejected_home = _rejected_home(home)
487
+ if rejected_home is not None:
488
+ return rejected_home
489
+ rejected_option = _rejected_option(timeout, notebook, output_format)
490
+ if rejected_option is not None:
491
+ return rejected_option
492
+ # runner was the last public argument still reaching the
493
+ # catch-all with an unnamed reason (QG D2 r8, Francisca M3).
494
+ if not callable(runner):
495
+ return "runner must be callable"
496
+ return None
497
+
498
+
499
+ def _rejected_option(
500
+ timeout: object, notebook: str | None, output_format: str | None
501
+ ) -> str | None:
502
+ """Why one of the CLI options may not be used, or None."""
503
+ if not _valid_timeout(timeout):
504
+ return (
505
+ "timeout must be a finite number of seconds in "
506
+ f"(0, {_MAX_TIMEOUT}]"
507
+ )
508
+ if notebook is not None and not _valid_notebook(notebook):
509
+ return f"notebook must match {_NOTEBOOK_PATTERN}"
510
+ if output_format is not None and not _valid_format(output_format):
511
+ return f"format not allowed; known: {sorted(ALLOWED_FORMATS)}"
512
+ return None
513
+
514
+
515
+ def _valid_format(output_format: object) -> bool:
516
+ return (
517
+ isinstance(output_format, str) and output_format in ALLOWED_FORMATS
518
+ )
519
+
520
+
521
+ def _valid_timeout(timeout: object) -> bool:
522
+ """A finite, positive, day-bounded number of seconds.
523
+
524
+ bool is an int subclass, so True would otherwise pass as a 1s
525
+ timeout. inf and 10**30 passed a sign-and-type check and made
526
+ ``subprocess.run`` raise OverflowError, which ``_invoke`` does not
527
+ catch — so the validator whose whole job is to say "positive
528
+ number" handed the runner a number it cannot take (QG D2 r7,
529
+ Francisca M3).
530
+ """
531
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
532
+ return False
533
+ return math.isfinite(timeout) and 0 < timeout <= _MAX_TIMEOUT
534
+
535
+
536
+ def _rejected_home(home: object) -> str | None:
537
+ """Why *home* may not be used, or None.
538
+
539
+ Both screens run BEFORE anything touches disk, because
540
+ ``policy.evaluate`` writes D1's audit trail and SALT under
541
+ ``home/.arkaos/egress`` before ``_prepare_home`` sees anything:
542
+ guarding only the payload home left the salt — the value that
543
+ makes D1's token digests non-reversible — at a planted link's
544
+ target (QG D2 r9, Francisca B1), and ``Path.home()`` raising
545
+ reached D1's path helper as "client error" (r6, Francisca B1).
546
+ """
547
+ if home is None:
548
+ if _home_unresolvable():
549
+ return "home directory could not be determined"
550
+ return _rejected_resources(None)
551
+ if not isinstance(home, Path):
552
+ return f"home must be a Path, not {type(home).__name__}"
553
+ return _rejected_shape(home) or _rejected_resources(home)
554
+
555
+
556
+ def _rejected_resources(home: Path | None) -> str | None:
557
+ """Screen every path a call reads or writes under *home*.
558
+
559
+ Screening ``home/.arkaos`` alone left two positions and one whole
560
+ door open: ``Path.parents`` excludes the node, and the ``home is
561
+ None`` branch — the door every production caller uses — had no
562
+ screen at all. An attacker owning the link owns
563
+ ``redaction-clients.json``, hence the guard's whole notion of a
564
+ client identifier, so a real client name left the machine
565
+ unredacted with ok=True (QG D2 r10, Francisca B1).
566
+ """
567
+ for resource in _resources_under(home):
568
+ traversal = _rejected_ancestor(resource)
569
+ if traversal is not None:
570
+ return f"payload home rejected: {traversal}"
571
+ return None
572
+
573
+
574
+ def _resources_under(home: Path | None) -> tuple[Path, ...]:
575
+ """Every path a call reads or writes. The payload directory is
576
+ listed by its DEFAULT location; an env override is screened in
577
+ ``_prepare_home``, which owns that door."""
578
+ return (
579
+ _default_payload_dir(home or Path.home()),
580
+ telemetry_path(home),
581
+ *(helper(home) for helper in _EGRESS_PATH_HELPERS),
582
+ )
583
+
584
+
585
+ def _egress_path_helpers() -> tuple[Callable[[Path | None], Path], ...]:
586
+ """Every ``default_*_path`` D1 exports, discovered rather than
587
+ listed.
588
+
589
+ A hand-written list missed ``allowlist.default_allowlist_path``,
590
+ which ``policy.evaluate`` READS — and an attacker owning that file
591
+ turns a denial into an allow (QG D2 r12, Francisca B1). Discovery
592
+ means a fifth helper is screened the day D1 adds it, instead of
593
+ the day someone notices.
594
+ """
595
+ return tuple(
596
+ getattr(module, name)
597
+ for module in _egress_modules()
598
+ for name in sorted(dir(module))
599
+ if name.startswith("default_") and name.endswith("_path")
600
+ )
601
+
602
+
603
+ def _egress_modules() -> list[ModuleType]:
604
+ """Every module in ``core.egress``, enumerated from the package.
605
+
606
+ A literal tuple was self-maintaining over HELPERS and hand-listed
607
+ over MODULES: ``redact`` was in neither the code's tuple nor the
608
+ test's, and the spec asserted a universal that held only because
609
+ ``redact`` happens to export no path helper today — by
610
+ coincidence, not construction (QG D2 r13, Francisca B2).
611
+ """
612
+ return [
613
+ importlib.import_module(f"{egress.__name__}.{info.name}")
614
+ for info in pkgutil.iter_modules(egress.__path__)
615
+ ]
616
+
617
+
618
+ _EGRESS_PATH_HELPERS = _egress_path_helpers()
619
+
620
+
621
+ def _rejected_shape(home: Path) -> str | None:
622
+ """The env door was hardened for absoluteness in r5 and the
623
+ ARGUMENT door was not, so a relative home put the 0600 payload,
624
+ the telemetry trail, the egress audit trail AND the audit salt
625
+ under the process's cwd — routinely a git working tree, where
626
+ `git add -A` stages them (QG D2 r8, Francisca B1). When a value
627
+ has two doors into one resource, a fix on one is a spec change
628
+ for both."""
629
+ if not home.is_absolute():
630
+ return "home must be an absolute path"
631
+ return None
632
+
633
+
634
+ def _home_unresolvable() -> bool:
635
+ """True when no ``home`` was given and the OS cannot supply one.
636
+
637
+ Tested unconditionally. A first version skipped the probe whenever
638
+ ``NOTEBOOKLM_HOME`` was set, on the premise that an override makes
639
+ the default irrelevant — false, because D1's
640
+ ``default_redaction_config_path`` also falls back to
641
+ ``Path.home()`` and never consults that variable. A container with
642
+ no HOME and a hand-set NOTEBOOKLM_HOME is the realistic config,
643
+ and it reached the catch-all (QG D2 r7, Francisca B1).
644
+ """
645
+ try:
646
+ Path.home()
647
+ except RuntimeError:
648
+ return True
649
+ return False
650
+
651
+
652
+ def _valid_notebook(notebook: object) -> bool:
653
+ return isinstance(notebook, str) and bool(_NOTEBOOK_RE.match(notebook))
654
+
655
+
656
+ def _cleared_to_send(
657
+ text: object, action: str, home: Path | None
658
+ ) -> tuple[str, str] | NotebookLMResult:
659
+ """``(redacted_text, payload_digest)``, or the result that denies it.
660
+
661
+ Runs BEFORE any process plumbing exists, so a denied payload never
662
+ reaches argv, an env var or a temp file. ``evaluate`` rather than
663
+ ``enforce``: the decision carries BOTH digests, and the telemetry
664
+ must key on the original payload — an operator asking "did this
665
+ ever leave?" greps the text they wrote, not its redaction (QG D2
666
+ r1, Francisca M2).
667
+
668
+ Union return: this is the egress denial, the module's core safety
669
+ property, and it was the last refusal still riding a tuple
670
+ sentinel the type checker cannot enforce (QG D2 r3, Francisca M6).
671
+ """
672
+ try:
673
+ decision = policy.evaluate(
674
+ text, "notebooklm", home=home,
675
+ config_path=redaction_config_path(home),
676
+ )
677
+ except Exception as exc: # the guard itself failed — deny, not send
678
+ return _degraded(action, f"egress guard error: {type(exc).__name__}")
679
+ if not decision.allowed or decision.redacted_text is None:
680
+ return _denial(action, decision)
681
+ return decision.redacted_text, decision.payload_sha256
682
+
683
+
684
+ def _denial(action: str, decision: policy.EgressDecision) -> NotebookLMResult:
685
+ """KINDS only — a finding token names a client identifier, a secret
686
+ label or a home path (D1 audit contract)."""
687
+ kinds = [f.kind for f in decision.findings]
688
+ return NotebookLMResult(
689
+ action=action, denied=True,
690
+ reason="egress denied: " + ", ".join(kinds),
691
+ finding_kinds=kinds,
692
+ payload_sha256=decision.payload_sha256,
693
+ )
694
+
695
+
696
+ def _run(
697
+ cleared: str,
698
+ digest: str,
699
+ action: str,
700
+ home: Path | None,
701
+ timeout: int,
702
+ options: list[str],
703
+ binary: str,
704
+ runner: Runner,
705
+ ) -> NotebookLMResult:
706
+ redacted_digest = policy.payload_digest(cleared)
707
+ target = _payload_dir(action, home, digest)
708
+ if isinstance(target, NotebookLMResult):
709
+ return target
710
+ try:
711
+ payload_file = _write_payload(cleared, target)
712
+ except OSError as exc:
713
+ return _degraded(action, f"payload not writable: {_why(exc)}", digest)
714
+ argv = [binary, action, "--input", str(payload_file), *options]
715
+ try:
716
+ return _outcome(
717
+ argv, action, timeout, digest, redacted_digest, runner
718
+ )
719
+ finally:
720
+ # A real finally: the plain call this replaced left the
721
+ # operator's research on disk on every unhandled path, and on
722
+ # Ctrl-C during the 180s timeout (QG D2 r1, Francisca B2).
723
+ _unlink_quiet(payload_file)
724
+
725
+
726
+ def _outcome(
727
+ argv: list[str],
728
+ action: str,
729
+ timeout: int,
730
+ digest: str,
731
+ redacted_digest: str,
732
+ runner: Runner,
733
+ ) -> NotebookLMResult:
734
+ """What the CLI's run amounts to. Called inside ``_run``'s finally,
735
+ so the payload file is removed whichever way this returns."""
736
+ proc = _invoke(argv, action, timeout, digest, runner)
737
+ if isinstance(proc, NotebookLMResult):
738
+ return proc
739
+ if proc.returncode != 0:
740
+ return _degraded(action, f"{BINARY} exited {proc.returncode}", digest)
741
+ return NotebookLMResult(
742
+ action=action, ok=True, stdout=proc.stdout or "",
743
+ payload_sha256=digest, redacted_sha256=redacted_digest,
744
+ )
745
+
746
+
747
+ def _invoke(
748
+ argv: list[str], action: str, timeout: int, digest: str, runner: Runner
749
+ ) -> CompletedProcess | NotebookLMResult:
750
+ """Run the CLI. Failures are degradations, never exceptions."""
751
+ try:
752
+ return runner(
753
+ argv, capture_output=True, text=True, timeout=timeout,
754
+ shell=False, # argv list, never a command string
755
+ )
756
+ except subprocess.TimeoutExpired:
757
+ return _degraded(action, f"timed out after {timeout}s", digest)
758
+ except (OSError, ValueError) as exc:
759
+ # ValueError too: a NUL in argv raises it, and the contract
760
+ # says degrade, not crash (QG D2 r1, Francisca B1).
761
+ return _degraded(
762
+ action, f"could not run {BINARY}: {_why(exc)}", digest
763
+ )
764
+
765
+
766
+ def _payload_dir(
767
+ action: str, home: Path | None, digest: str
768
+ ) -> Path | NotebookLMResult:
769
+ """The prepared directory, or the degradation that explains itself.
770
+
771
+ Separate from the write so an unusable home is not reported as an
772
+ unwritable payload: the two have different remedies, and the
773
+ wrong label sends the operator to the wrong one.
774
+ """
775
+ try:
776
+ return _prepare_home(home)
777
+ except OSError as exc:
778
+ return _degraded(
779
+ action, f"payload home unusable: {_why(exc)}", digest
780
+ )
781
+ except ValueError as exc:
782
+ return _degraded(action, f"payload home rejected: {exc}", digest)
783
+
784
+
785
+ def _write_payload(cleared: str, target: Path) -> Path:
786
+ """The redacted payload in a 0600 file under NOTEBOOKLM_HOME.
787
+
788
+ A file rather than argv: a command line is visible to every
789
+ process on the machine, and the redacted text may still be the
790
+ operator's research.
791
+ """
792
+ fd, name = tempfile.mkstemp(prefix="payload-", suffix=".txt", dir=target)
793
+ # surrogatepass mirrors policy.payload_digest: a lone surrogate is
794
+ # routine input the guard already judged, and crashing here
795
+ # re-opened the hole D1 closed one layer down (QG D2 r1, Francisca B1).
796
+ with os.fdopen(fd, "w", encoding="utf-8", errors="surrogatepass") as fh:
797
+ fh.write(cleared)
798
+ os.chmod(name, 0o600)
799
+ return Path(name)
800
+
801
+
802
+ def _degraded(action: str, reason: str, digest: str = "") -> NotebookLMResult:
803
+ return NotebookLMResult(
804
+ action=action, degraded=True, reason=reason, payload_sha256=digest
805
+ )
806
+
807
+
808
+ def _record(result: NotebookLMResult, home: Path | None) -> NotebookLMResult:
809
+ """Append one telemetry line. Best-effort: never fails the call.
810
+
811
+ 0700 dir, 0600 file, matching the D1 audit trail: the record of a
812
+ decision must not be protected less than the payload it describes
813
+ (QG D2 r1, Francisca B4 — it was 0644 in a 0755 dir).
814
+
815
+ Symlink-hardened on the same terms as ``_prepare_home``: the
816
+ directory was chmod'ed and the trail opened THROUGH the path, so a
817
+ link under ``~/.arkaos`` took a victim directory to 0700 and got
818
+ the trail written inside it — the hardening stopped three
819
+ functions short (QG D2 r3, Francisca M2).
820
+ """
821
+ try:
822
+ path = telemetry_path(home)
823
+ line = json.dumps(
824
+ {"ts": datetime.now(UTC).isoformat(), **result.to_telemetry()},
825
+ sort_keys=True,
826
+ )
827
+ _write_trail(path, line)
828
+ except Exception:
829
+ pass
830
+ return result
831
+
832
+
833
+ def _write_trail(path: Path, line: str) -> None:
834
+ """Append *line* to a 0600 trail in a 0700 directory, following no
835
+ link at either level.
836
+
837
+ Three mechanisms, all proven load-bearing by mutation: the
838
+ ancestors via ``_rejected_ancestor``, the directory via
839
+ ``_open_dir_nofollow``, the file via ``O_NOFOLLOW``.
840
+
841
+ No ``is_symlink()`` pre-check here, unlike :func:`_prepare_home`,
842
+ and the distinction is the diagnostic rather than reachability:
843
+ that pre-check is reachable — it fires on a dangling
844
+ symlink and gives the operator "rejected: path is a symlink"
845
+ instead of "unusable: FileExistsError". Here the exception is
846
+ swallowed by :func:`_record`, so a pre-check buys no reason anyone
847
+ will read (QG D2 r3, Francisca M2; refined QG D2 r1, Eduardo B9).
848
+ """
849
+ traversal = _rejected_ancestor(path)
850
+ if traversal is not None:
851
+ raise ValueError(traversal)
852
+ path.parent.mkdir(parents=True, exist_ok=True)
853
+ dir_fd = _open_dir_nofollow(path.parent)
854
+ try:
855
+ os.fchmod(dir_fd, 0o700)
856
+ finally:
857
+ os.close(dir_fd)
858
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW
859
+ fd = os.open(path, flags, 0o600)
860
+ with os.fdopen(fd, "a", encoding="utf-8") as handle:
861
+ os.fchmod(fd, 0o600)
862
+ handle.write(line + "\n")
863
+
864
+
865
+ def _why(exc: BaseException) -> str:
866
+ """An error's SHAPE, never its message.
867
+
868
+ ``str(OSError)`` embeds the path it failed on, which for this
869
+ module is routinely under the operator's home — the reason field
870
+ is persisted, so it must carry no more than the type and errno
871
+ (QG D2 r1, Francisca B4).
872
+ """
873
+ errno = getattr(exc, "errno", None)
874
+ return (
875
+ f"{type(exc).__name__}(errno={errno})"
876
+ if errno is not None
877
+ else type(exc).__name__
878
+ )
879
+
880
+
881
+ def _unlink_quiet(path: Path) -> None:
882
+ with contextlib.suppress(OSError):
883
+ path.unlink()