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.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/core/egress/policy.py +84 -32
- package/core/harness/cli.py +143 -0
- package/core/harness/drift.py +160 -9
- package/core/harness/manager.py +577 -0
- package/core/harness/manifest.py +5 -1
- package/core/kb/__init__.py +9 -0
- package/core/kb/nlm_client.py +883 -0
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/cli.js +27 -0
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- 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()
|