llmt-cli 0.2.0__py3-none-any.whl

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.
llmt/scanner.py ADDED
@@ -0,0 +1,328 @@
1
+ """Local model-file scanner for ``llmt scan`` (WP-14.4-S).
2
+
3
+ Two-phase design:
4
+
5
+ 1. **Candidate gathering** — walk a set of scan roots, fingerprinting known
6
+ model layouts, applying a *size + extension prefilter* so we never hash 4 TB
7
+ of unrelated data. Gathering is cheap (stat only).
8
+ 2. **Hashing** — full-file streaming SHA-256 over the (much smaller) candidate
9
+ set, with progress. The full-file hash is the ONLY seedability signal the
10
+ downstream matcher trusts: a truncated / wrong-size / corrupt file simply
11
+ produces a different digest and can never be "matched".
12
+
13
+ Hostile-filesystem hardening (red-team axis):
14
+ * Symlinks are never followed — neither symlinked directories (no traversal
15
+ out of the scan roots, no symlink loops) nor symlinked files (we skip them
16
+ entirely rather than hash whatever they point at).
17
+ * Only regular files are ever opened: device nodes, FIFOs, sockets are
18
+ skipped (``entry.is_file(follow_symlinks=False)``).
19
+ * Recursion depth is bounded (``max_depth``) and visited directories are
20
+ de-duplicated by ``(st_dev, st_ino)`` to survive bind-mount / hardlink loops.
21
+ * Every candidate's real path is re-checked to still live under the root's
22
+ real path (defence in depth on top of the symlink skip).
23
+
24
+ Layout honesty:
25
+ * ``ollama`` — we genuinely parse the manifest tree to map blob digests
26
+ (blobs are named ``sha256-<digest>``, NOT ``*.gguf``) to a friendly
27
+ ``model:tag`` label. Blobs are matched by digest.
28
+ * ``lmstudio`` / ``text-generation-webui`` / ``generic`` — we detect the root
29
+ but scan it *generically* by model file-extension; we do not parse their
30
+ per-tool metadata. This is documented as best-effort.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import json
37
+ import os
38
+ import re
39
+ import stat
40
+ from dataclasses import dataclass
41
+ from typing import Callable, Iterator, Optional
42
+
43
+ _CHUNK = 64 * 1024 # streaming SHA-256 block size — the trust core
44
+
45
+
46
+ def _safe_sha256(path: str) -> str:
47
+ """Full-file SHA-256 with an OPEN-TIME re-check that closes the TOCTOU gap
48
+ between the candidate walk (which vets each entry as a regular file) and the
49
+ hashing pass (which re-opens by path). A hostile writer in the scan root
50
+ could, in that window, swap a vetted regular file for a symlink (-> we would
51
+ hash and leak the digest of whatever it points at) or a FIFO (-> a blocking
52
+ read would hang the scan forever). We defeat both at open() time:
53
+
54
+ * ``O_NOFOLLOW`` — a symlink swapped in is refused (ELOOP), never followed.
55
+ * ``O_NONBLOCK`` — opening a FIFO with no writer returns immediately
56
+ instead of blocking; we then reject it by type.
57
+ * ``fstat`` + ``S_ISREG`` on the OPEN fd — only a genuine regular file is
58
+ ever read; anything else raises OSError (the caller skips it).
59
+ """
60
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
61
+ fd = os.open(path, flags)
62
+ try:
63
+ st = os.fstat(fd)
64
+ if not stat.S_ISREG(st.st_mode):
65
+ raise OSError(f"not a regular file at hash time: {path!r}")
66
+ h = hashlib.sha256()
67
+ while True:
68
+ chunk = os.read(fd, _CHUNK)
69
+ if not chunk:
70
+ break
71
+ h.update(chunk)
72
+ return h.hexdigest()
73
+ finally:
74
+ os.close(fd)
75
+
76
+ DEFAULT_MIN_SIZE = 1 * 1024 * 1024 # 1 MiB: below this it is not a model shard
77
+ DEFAULT_MAX_DEPTH = 40
78
+
79
+ KNOWN_MODEL_EXTS = {
80
+ ".gguf", ".ggml", ".safetensors", ".bin", ".pt", ".pth",
81
+ ".onnx", ".gptq", ".awq", ".npz", ".mlmodel",
82
+ }
83
+
84
+ _OLLAMA_BLOB_RE = re.compile(r"^sha256[-:][0-9a-f]{64}$")
85
+ _HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
86
+
87
+ LAYOUT_GENERIC = "generic"
88
+ LAYOUT_OLLAMA = "ollama"
89
+ LAYOUT_LMSTUDIO = "lmstudio"
90
+ LAYOUT_TEXTGEN = "text-generation-webui"
91
+ LAYOUT_PATH = "path"
92
+
93
+ SkipFn = Optional[Callable[[str, str], None]]
94
+
95
+
96
+ @dataclass
97
+ class Candidate:
98
+ """A file that passed the cheap prefilter and is worth hashing."""
99
+ path: str
100
+ size: int
101
+ layout: str
102
+
103
+
104
+ @dataclass
105
+ class HashedFile:
106
+ """A candidate with its computed full-file SHA-256."""
107
+ path: str
108
+ size: int
109
+ sha256: str
110
+ layout: str
111
+ label: Optional[str] = None # friendly name (e.g. Ollama model:tag), if known
112
+
113
+
114
+ # --------------------------------------------------------------------------- #
115
+ # Root discovery
116
+ # --------------------------------------------------------------------------- #
117
+
118
+ def default_roots() -> list[tuple[str, str]]:
119
+ """Known model-store roots that currently exist, tagged with their layout.
120
+
121
+ We only return directories that exist so the scan does not invent paths.
122
+ Order is stable but not significant (candidates are de-duplicated by real
123
+ path downstream via the (dev, ino) visited-set on directories).
124
+ """
125
+ home = os.path.expanduser("~")
126
+ candidates = [
127
+ (os.path.join(home, ".ollama", "models"), LAYOUT_OLLAMA),
128
+ ("/usr/share/ollama/.ollama/models", LAYOUT_OLLAMA),
129
+ ("/var/lib/ollama/.ollama/models", LAYOUT_OLLAMA),
130
+ (os.path.join(home, ".cache", "lm-studio", "models"), LAYOUT_LMSTUDIO),
131
+ (os.path.join(home, ".lmstudio", "models"), LAYOUT_LMSTUDIO),
132
+ (os.path.join(home, "text-generation-webui", "models"), LAYOUT_TEXTGEN),
133
+ (os.path.join(home, "models"), LAYOUT_GENERIC),
134
+ ("/models", LAYOUT_GENERIC),
135
+ ]
136
+ out: list[tuple[str, str]] = []
137
+ for path, layout in candidates:
138
+ if os.path.isdir(path):
139
+ out.append((path, layout))
140
+ return out
141
+
142
+
143
+ # --------------------------------------------------------------------------- #
144
+ # Ollama manifest parsing (the one layout we truly parse)
145
+ # --------------------------------------------------------------------------- #
146
+
147
+ def ollama_labels(models_root: str) -> dict[str, str]:
148
+ """Map ``blob-digest -> "model:tag"`` by parsing Ollama's manifest tree.
149
+
150
+ Best-effort and fully defensive: any unreadable/oversized/non-JSON manifest
151
+ is skipped, never fatal. The returned digests are lowercase 64-hex; they are
152
+ used only to attach a friendly label to a blob we still hash ourselves.
153
+ """
154
+ labels: dict[str, str] = {}
155
+ man_root = os.path.join(models_root, "manifests")
156
+ if not os.path.isdir(man_root):
157
+ return labels
158
+ for dirpath, _dirnames, filenames in os.walk(man_root, followlinks=False):
159
+ for fn in filenames:
160
+ fp = os.path.join(dirpath, fn)
161
+ try:
162
+ if os.path.islink(fp) or not os.path.isfile(fp):
163
+ continue
164
+ if os.path.getsize(fp) > 1_000_000: # a manifest is tiny JSON
165
+ continue
166
+ with open(fp, "rb") as fh:
167
+ data = json.loads(fh.read().decode("utf-8", "replace"))
168
+ except (OSError, ValueError):
169
+ continue
170
+ if not isinstance(data, dict):
171
+ continue
172
+ model = os.path.basename(dirpath)
173
+ label = f"{model}:{fn}"
174
+ layers = data.get("layers")
175
+ if not isinstance(layers, list):
176
+ continue
177
+ for layer in layers:
178
+ if not isinstance(layer, dict):
179
+ continue
180
+ dig = layer.get("digest")
181
+ mtype = str(layer.get("mediaType") or "")
182
+ if isinstance(dig, str) and "model" in mtype:
183
+ d = dig.split(":", 1)[-1].lower()
184
+ if _HEX64_RE.match(d):
185
+ labels[d] = label
186
+ return labels
187
+
188
+
189
+ # --------------------------------------------------------------------------- #
190
+ # Candidate walk (hostile-fs hardened)
191
+ # --------------------------------------------------------------------------- #
192
+
193
+ def _ext_ok(name: str, exts: set[str]) -> bool:
194
+ dot = name.rfind(".")
195
+ if dot < 0:
196
+ return False
197
+ return name[dot:].lower() in exts
198
+
199
+
200
+ def _within(base_real: str, path_real: str) -> bool:
201
+ try:
202
+ common = os.path.commonpath([base_real, path_real])
203
+ except ValueError:
204
+ return False
205
+ return os.path.normcase(common) == os.path.normcase(base_real)
206
+
207
+
208
+ def _skip(on_skip: SkipFn, path: str, reason: str) -> None:
209
+ if on_skip is not None:
210
+ on_skip(path, reason)
211
+
212
+
213
+ def iter_candidates(
214
+ roots: list[tuple[str, str]],
215
+ *,
216
+ min_size: int = DEFAULT_MIN_SIZE,
217
+ exts: Optional[set[str]] = None,
218
+ all_files: bool = False,
219
+ max_depth: int = DEFAULT_MAX_DEPTH,
220
+ on_skip: SkipFn = None,
221
+ ) -> Iterator[Candidate]:
222
+ """Yield prefiltered candidate files under ``roots`` without ever following
223
+ a symlink out of a root or opening a non-regular file."""
224
+ exts = KNOWN_MODEL_EXTS if exts is None else exts
225
+ seen_dirs: set[tuple[int, int]] = set()
226
+ for root, layout in roots:
227
+ root_abs = os.path.abspath(os.path.expanduser(root))
228
+ if not os.path.isdir(root_abs):
229
+ _skip(on_skip, root_abs, "root-missing")
230
+ continue
231
+ # The boundary is the root's REAL path; a candidate whose realpath
232
+ # leaves it is rejected even though symlinks are already skipped.
233
+ base_real = os.path.realpath(root_abs)
234
+ yield from _scan_dir(
235
+ root_abs, layout, base_real, 0,
236
+ min_size, exts, all_files, max_depth, seen_dirs, on_skip,
237
+ )
238
+
239
+
240
+ def _scan_dir(
241
+ dirpath: str, layout: str, base_real: str, depth: int,
242
+ min_size: int, exts: set[str], all_files: bool, max_depth: int,
243
+ seen: set[tuple[int, int]], on_skip: SkipFn,
244
+ ) -> Iterator[Candidate]:
245
+ if depth > max_depth:
246
+ _skip(on_skip, dirpath, "max-depth")
247
+ return
248
+ try:
249
+ dstat = os.stat(dirpath) # dir we chose to descend into (never a symlink)
250
+ except OSError as exc:
251
+ _skip(on_skip, dirpath, f"stat-dir: {exc}")
252
+ return
253
+ key = (dstat.st_dev, dstat.st_ino)
254
+ if key in seen:
255
+ _skip(on_skip, dirpath, "loop")
256
+ return
257
+ seen.add(key)
258
+
259
+ try:
260
+ scan_it = os.scandir(dirpath)
261
+ except OSError as exc:
262
+ _skip(on_skip, dirpath, f"opendir: {exc}")
263
+ return
264
+
265
+ with scan_it:
266
+ for entry in scan_it:
267
+ try:
268
+ if entry.is_symlink():
269
+ _skip(on_skip, entry.path, "symlink")
270
+ continue
271
+ if entry.is_dir(follow_symlinks=False):
272
+ yield from _scan_dir(
273
+ entry.path, layout, base_real, depth + 1,
274
+ min_size, exts, all_files, max_depth, seen, on_skip,
275
+ )
276
+ continue
277
+ if not entry.is_file(follow_symlinks=False):
278
+ # device node / FIFO / socket / other — never open it.
279
+ _skip(on_skip, entry.path, "not-regular")
280
+ continue
281
+ est = entry.stat(follow_symlinks=False)
282
+ size = est.st_size
283
+ if size < min_size:
284
+ continue
285
+ name = entry.name
286
+ if not (all_files or _ext_ok(name, exts)
287
+ or _OLLAMA_BLOB_RE.match(name)):
288
+ continue
289
+ if not _within(base_real, os.path.realpath(entry.path)):
290
+ _skip(on_skip, entry.path, "escapes-root")
291
+ continue
292
+ yield Candidate(path=entry.path, size=size, layout=layout)
293
+ except OSError as exc:
294
+ _skip(on_skip, entry.path, f"stat: {exc}")
295
+ continue
296
+
297
+
298
+ # --------------------------------------------------------------------------- #
299
+ # Hashing
300
+ # --------------------------------------------------------------------------- #
301
+
302
+ def hash_candidates(
303
+ candidates: list[Candidate],
304
+ *,
305
+ labels: Optional[dict[str, str]] = None,
306
+ progress: Optional[Callable[[int, int, str], None]] = None,
307
+ ) -> list[HashedFile]:
308
+ """Full-file SHA-256 every candidate; attach a label by digest when known.
309
+
310
+ Unreadable files (races, permission flips) are skipped rather than fatal.
311
+ """
312
+ labels = labels or {}
313
+ out: list[HashedFile] = []
314
+ total = len(candidates)
315
+ for i, cand in enumerate(candidates, 1):
316
+ if progress is not None:
317
+ progress(i, total, cand.path)
318
+ try:
319
+ digest = _safe_sha256(cand.path)
320
+ except OSError:
321
+ # symlink/FIFO/device swapped in after the walk, race, perm flip:
322
+ # skip rather than hash something untrusted or block forever.
323
+ continue
324
+ out.append(HashedFile(
325
+ path=cand.path, size=cand.size, sha256=digest,
326
+ layout=cand.layout, label=labels.get(digest),
327
+ ))
328
+ return out
@@ -0,0 +1,15 @@
1
+ """llmt telemetry — the OPT-IN agent write-API client (WP-14.4-T, BUILD2 14B).
2
+
3
+ Telemetry is a SEPARATE, explicit, OFF-BY-DEFAULT, revocable opt-in (DR-27).
4
+ Nothing in this package touches the network unless the user has run
5
+ ``llmt telemetry enable`` and a token is present. Scan data and local file
6
+ PATHS never leave the machine: only torrent infohashes/names/states/progress,
7
+ artifact SHA-256s, and challenge-range hashes are ever sent. See
8
+ :mod:`llmt.telemetry.consent` for the exact, user-visible statement.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ AGENT_API_PATH = "/agent/v1"
14
+
15
+ __all__ = ["AGENT_API_PATH"]
@@ -0,0 +1,217 @@
1
+ """Artifact-level possession challenges (H2) + local artifact location.
2
+
3
+ A challenge references an artifact by ``sha256`` and asks for
4
+ ``sha256(salt_bytes || file_bytes[offset : offset+length])``. We locate the
5
+ LOCAL file whose FULL sha256 equals the challenged digest (via the same
6
+ open-time-hardened full-file hash the scanner uses), then hash the requested
7
+ byte range. If the file is absent, or too short to cover the range, we answer
8
+ NOTHING for it (honest failure) — we NEVER fabricate a hash.
9
+
10
+ Full-file digests are cached by (realpath, size, mtime_ns) under the llmt home
11
+ so repeated beats do not re-hash unchanged files.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import stat
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+
24
+ from ..errors import ApiError
25
+ from ..scanner import _safe_sha256
26
+ from . import config as tcfg
27
+
28
+ _HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
29
+ _MAX_CACHE_ENTRIES = 8192
30
+
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # Range hashing (the possession proof)
34
+ # --------------------------------------------------------------------------- #
35
+
36
+ def decode_salt(salt_hex: object) -> bytes:
37
+ """Decode the server-provided hex salt to raw bytes (contract (b)2)."""
38
+ if not isinstance(salt_hex, str):
39
+ raise ApiError("challenge salt is not a string.")
40
+ s = salt_hex.strip()
41
+ if s == "":
42
+ return b""
43
+ try:
44
+ return bytes.fromhex(s)
45
+ except ValueError as exc:
46
+ raise ApiError("challenge salt is not valid hex.") from exc
47
+
48
+
49
+ def hash_range(path: str, salt: bytes, offset: int, length: int,
50
+ chunk_size: int = 1024 * 1024) -> Optional[str]:
51
+ """Return ``sha256(salt || file[offset:offset+length])`` as hex, or None
52
+ if the file is missing / not a regular file / too short for the range.
53
+
54
+ Symlinks are refused at open time (O_NOFOLLOW); only a genuine regular
55
+ file is ever read. Reads are streamed so a large range never loads wholly
56
+ into memory.
57
+ """
58
+ if offset < 0 or length < 0:
59
+ return None
60
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
61
+ try:
62
+ fd = os.open(path, flags)
63
+ except OSError:
64
+ return None
65
+ try:
66
+ st = os.fstat(fd)
67
+ if not stat.S_ISREG(st.st_mode):
68
+ return None
69
+ if st.st_size < offset + length:
70
+ return None # range not fully present -> fail honestly
71
+ os.lseek(fd, offset, os.SEEK_SET)
72
+ h = hashlib.sha256()
73
+ h.update(salt)
74
+ remaining = length
75
+ while remaining > 0:
76
+ chunk = os.read(fd, min(chunk_size, remaining))
77
+ if not chunk:
78
+ return None # truncated under us -> honest failure
79
+ h.update(chunk)
80
+ remaining -= len(chunk)
81
+ return h.hexdigest()
82
+ except OSError:
83
+ return None
84
+ finally:
85
+ os.close(fd)
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Local artifact location (full-file sha256, cached)
90
+ # --------------------------------------------------------------------------- #
91
+
92
+ class ArtifactLocator:
93
+ """Find the local file for a given full-file SHA-256 among candidate paths,
94
+ caching digests by (realpath, size, mtime_ns)."""
95
+
96
+ def __init__(self, cache_path: Optional[str] = None):
97
+ self.cache_path = cache_path or os.path.join(
98
+ tcfg.llmt_home(), "hash-cache.json")
99
+ self._cache: dict = {}
100
+ self._dirty = False
101
+ self._load()
102
+
103
+ def _load(self) -> None:
104
+ try:
105
+ with open(self.cache_path, "r", encoding="utf-8") as fh:
106
+ data = json.load(fh)
107
+ if isinstance(data, dict):
108
+ self._cache = {k: v for k, v in data.items()
109
+ if isinstance(k, str) and isinstance(v, str)
110
+ and _HEX64_RE.match(v)}
111
+ except (OSError, ValueError):
112
+ self._cache = {}
113
+
114
+ @staticmethod
115
+ def _key(path: str) -> Optional[str]:
116
+ try:
117
+ st = os.stat(path)
118
+ except OSError:
119
+ return None
120
+ rp = os.path.realpath(path)
121
+ return f"{rp}\x00{st.st_size}\x00{st.st_mtime_ns}"
122
+
123
+ def sha256_of(self, path: str) -> Optional[str]:
124
+ key = self._key(path)
125
+ if key is not None:
126
+ cached = self._cache.get(key)
127
+ if cached is not None:
128
+ return cached
129
+ try:
130
+ digest = _safe_sha256(path)
131
+ except OSError:
132
+ return None
133
+ if key is not None and len(self._cache) < _MAX_CACHE_ENTRIES:
134
+ self._cache[key] = digest
135
+ self._dirty = True
136
+ return digest
137
+
138
+ def locate(self, target_sha256: str, candidate_paths) -> Optional[str]:
139
+ """Return the first candidate whose full sha256 == target, else None.
140
+ Short-circuits on the first match so a whole tree need not be hashed."""
141
+ target = (target_sha256 or "").strip().lower()
142
+ if not _HEX64_RE.match(target):
143
+ return None
144
+ for path in candidate_paths:
145
+ if self.sha256_of(path) == target:
146
+ return path
147
+ return None
148
+
149
+ def save(self) -> None:
150
+ if not self._dirty:
151
+ return
152
+ try:
153
+ os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
154
+ tmp = self.cache_path + ".tmp"
155
+ with open(tmp, "w", encoding="utf-8") as fh:
156
+ json.dump(self._cache, fh)
157
+ os.replace(tmp, self.cache_path)
158
+ try:
159
+ os.chmod(self.cache_path, 0o600)
160
+ except OSError:
161
+ pass
162
+ self._dirty = False
163
+ except OSError:
164
+ pass
165
+
166
+
167
+ # --------------------------------------------------------------------------- #
168
+ # Challenge parsing + answering
169
+ # --------------------------------------------------------------------------- #
170
+
171
+ @dataclass
172
+ class Challenge:
173
+ nonce: str
174
+ artifact_sha256: str
175
+ offset: int
176
+ length: int
177
+ salt: str
178
+
179
+ @classmethod
180
+ def parse(cls, raw: object) -> Optional["Challenge"]:
181
+ if not isinstance(raw, dict):
182
+ return None
183
+ nonce = raw.get("nonce")
184
+ sha = raw.get("artifact_sha256")
185
+ offset = raw.get("offset")
186
+ length = raw.get("length")
187
+ salt = raw.get("salt")
188
+ if not isinstance(nonce, str) or not nonce:
189
+ return None
190
+ if not isinstance(sha, str) or not _HEX64_RE.match(sha.strip().lower()):
191
+ return None
192
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
193
+ return None
194
+ if isinstance(length, bool) or not isinstance(length, int) or length < 0:
195
+ return None
196
+ if not isinstance(salt, str):
197
+ return None
198
+ return cls(nonce=nonce, artifact_sha256=sha.strip().lower(),
199
+ offset=offset, length=length, salt=salt)
200
+
201
+
202
+ def answer_challenge(challenge: Challenge, candidate_paths, locator,
203
+ chunk_size: int) -> Optional[str]:
204
+ """Resolve the local file for ``challenge.artifact_sha256`` and return the
205
+ range hash, or None when we cannot honestly answer (file absent/incomplete).
206
+ """
207
+ path = locator.locate(challenge.artifact_sha256, candidate_paths)
208
+ if path is None:
209
+ return None
210
+ try:
211
+ salt = decode_salt(challenge.salt)
212
+ except ApiError:
213
+ # A hostile / malformed salt is a per-challenge honest failure, never
214
+ # a reason to abort the whole beat (which would retry forever --loop).
215
+ return None
216
+ return hash_range(path, salt, challenge.offset, challenge.length,
217
+ chunk_size=chunk_size)