logogram 0.1.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.
- logogram/__init__.py +6 -0
- logogram/__main__.py +5 -0
- logogram/analysis.py +419 -0
- logogram/atp.py +120 -0
- logogram/backends/__init__.py +5 -0
- logogram/backends/base.py +202 -0
- logogram/backends/hub.py +375 -0
- logogram/backends/saes.py +277 -0
- logogram/backends/transformer_lens.py +872 -0
- logogram/cli.py +496 -0
- logogram/compare.py +177 -0
- logogram/datasets.py +159 -0
- logogram/direct.py +193 -0
- logogram/engine.py +550 -0
- logogram/examples/ioi-gpt2/.gitignore +3 -0
- logogram/examples/ioi-gpt2/datasets/ioi.jsonl +32 -0
- logogram/examples/ioi-gpt2/experiments/ioi-head-patching/spec.json +42 -0
- logogram/examples/ioi-gpt2/project.json +6 -0
- logogram/exports.py +33 -0
- logogram/features.py +368 -0
- logogram/fileio.py +63 -0
- logogram/ioi.py +220 -0
- logogram/paths.py +204 -0
- logogram/project.py +444 -0
- logogram/prompts.py +204 -0
- logogram/research.py +84 -0
- logogram/results.py +240 -0
- logogram/runner.py +396 -0
- logogram/runs.py +98 -0
- logogram/sae.py +161 -0
- logogram/schema.py +302 -0
- logogram/server/__init__.py +1 -0
- logogram/server/app.py +1083 -0
- logogram/server/models.py +426 -0
- logogram/server/security.py +212 -0
- logogram/server/state.py +585 -0
- logogram/sites.py +249 -0
- logogram/spec.py +518 -0
- logogram/stats.py +171 -0
- logogram/steering.py +258 -0
- logogram/system.py +379 -0
- logogram/updates.py +194 -0
- logogram/verify.py +39 -0
- logogram/web_dist/assets/index-BvCU-2uy.js +54 -0
- logogram/web_dist/assets/index-DTr8_ucV.css +1 -0
- logogram/web_dist/assets/instrument-sans-latin-ext-standard-normal-C5E2Gvlv.woff2 +0 -0
- logogram/web_dist/assets/instrument-sans-latin-standard-normal-BVScPF0l.woff2 +0 -0
- logogram/web_dist/favicon.svg +1 -0
- logogram/web_dist/index.html +15 -0
- logogram-0.1.0.dist-info/METADATA +550 -0
- logogram-0.1.0.dist-info/RECORD +54 -0
- logogram-0.1.0.dist-info/WHEEL +4 -0
- logogram-0.1.0.dist-info/entry_points.txt +2 -0
- logogram-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Response models for the JSON API. Request bodies are defined beside their routes in app.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
from logogram.schema import RunListing, SiteSummary
|
|
10
|
+
from logogram.spec import ModelRef, PredictionSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Out(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# -- models and jobs ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelInfoOut(_Out):
|
|
21
|
+
id: str
|
|
22
|
+
revision: str | None
|
|
23
|
+
architecture: str
|
|
24
|
+
n_layers: int
|
|
25
|
+
n_heads: int
|
|
26
|
+
d_model: int
|
|
27
|
+
d_head: int
|
|
28
|
+
d_mlp: int | None
|
|
29
|
+
d_vocab: int
|
|
30
|
+
n_ctx: int
|
|
31
|
+
n_params: int | None
|
|
32
|
+
dtype: str
|
|
33
|
+
device: str
|
|
34
|
+
device_name: str
|
|
35
|
+
process_weights: bool
|
|
36
|
+
site_kinds: list[str]
|
|
37
|
+
backend: str
|
|
38
|
+
backend_version: str
|
|
39
|
+
extra: dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ModelStatus(_Out):
|
|
43
|
+
state: Literal["none", "loading", "ready", "error"]
|
|
44
|
+
id: str | None = None
|
|
45
|
+
model_id: str | None = None
|
|
46
|
+
stage: str | None = None
|
|
47
|
+
done: int | None = None
|
|
48
|
+
total: int | None = None
|
|
49
|
+
file: str | None = None
|
|
50
|
+
revision: str | None = None
|
|
51
|
+
error: str | None = None
|
|
52
|
+
info: ModelInfoOut | None = None
|
|
53
|
+
memory: int | None = None
|
|
54
|
+
ref: ModelRef | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class JobInfo(_Out):
|
|
58
|
+
id: str
|
|
59
|
+
kind: Literal["load_model", "load_sae", "run"]
|
|
60
|
+
title: str
|
|
61
|
+
status: Literal["running", "finished", "failed", "cancelled"]
|
|
62
|
+
run_id: str | None
|
|
63
|
+
project_session: str | None = None
|
|
64
|
+
progress: dict[str, Any]
|
|
65
|
+
error: str | None
|
|
66
|
+
started: float
|
|
67
|
+
version: int
|
|
68
|
+
cancelling: bool | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StartedRun(_Out):
|
|
72
|
+
run_id: str
|
|
73
|
+
job: JobInfo
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CancelOut(_Out):
|
|
77
|
+
job: JobInfo | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DraftSaved(_Out):
|
|
81
|
+
run_id: str
|
|
82
|
+
path: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- projects and files ------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DatasetListing(_Out):
|
|
89
|
+
name: str
|
|
90
|
+
path: str
|
|
91
|
+
modified: str | None
|
|
92
|
+
n: int | None = None
|
|
93
|
+
has_positions: bool | None = None
|
|
94
|
+
sha256: str | None = None
|
|
95
|
+
error: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ProjectInfo(_Out):
|
|
99
|
+
name: str
|
|
100
|
+
description: str
|
|
101
|
+
path: str
|
|
102
|
+
session_id: str
|
|
103
|
+
datasets: list[DatasetListing]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RecentProject(_Out):
|
|
107
|
+
path: str
|
|
108
|
+
name: str
|
|
109
|
+
opened: str | None
|
|
110
|
+
modified: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FolderEntry(_Out):
|
|
114
|
+
name: str
|
|
115
|
+
path: str
|
|
116
|
+
is_project: bool
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class FolderListing(_Out):
|
|
120
|
+
path: str
|
|
121
|
+
parent: str | None
|
|
122
|
+
is_project: bool
|
|
123
|
+
entries: list[FolderEntry]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class UpdateStatus(_Out):
|
|
127
|
+
current: str
|
|
128
|
+
released: str | None
|
|
129
|
+
automatic: bool | None
|
|
130
|
+
latest: str | None
|
|
131
|
+
available: bool
|
|
132
|
+
old: bool
|
|
133
|
+
checked_at: str | None
|
|
134
|
+
error: str | None
|
|
135
|
+
notes_url: str | None
|
|
136
|
+
command: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ServerState(_Out):
|
|
140
|
+
version: str
|
|
141
|
+
project: ProjectInfo | None
|
|
142
|
+
model: ModelStatus
|
|
143
|
+
job: JobInfo | None
|
|
144
|
+
first_run: bool
|
|
145
|
+
theme: Literal["light", "dark", "system"]
|
|
146
|
+
projects_parent: str
|
|
147
|
+
update: UpdateStatus
|
|
148
|
+
sae: dict[str, Any] = {"state": "none"}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Settings(BaseModel):
|
|
152
|
+
model_config = ConfigDict(extra="allow")
|
|
153
|
+
|
|
154
|
+
system_check_seen: bool | None = None
|
|
155
|
+
theme: str | None = None
|
|
156
|
+
update_check: bool | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class OkOut(_Out):
|
|
160
|
+
ok: bool
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# -- datasets and prompts ----------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PromptIssue(_Out):
|
|
167
|
+
index: int
|
|
168
|
+
kind: str
|
|
169
|
+
message: str
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DatasetDetail(_Out):
|
|
173
|
+
name: str
|
|
174
|
+
path: str
|
|
175
|
+
sha256: str
|
|
176
|
+
n: int
|
|
177
|
+
records: list[dict[str, Any]]
|
|
178
|
+
issues: list[PromptIssue] | None = None
|
|
179
|
+
lengths: list[int] | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class DatasetCreated(_Out):
|
|
183
|
+
name: str
|
|
184
|
+
path: str
|
|
185
|
+
n: int
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class IOITemplateOut(_Out):
|
|
189
|
+
id: str
|
|
190
|
+
text: str
|
|
191
|
+
default: bool
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TokenRow(_Out):
|
|
195
|
+
tokens: list[str]
|
|
196
|
+
ids: list[int]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class AnswerTokens(_Out):
|
|
200
|
+
text: str
|
|
201
|
+
tokens: list[str]
|
|
202
|
+
id: int | None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TokenStrip(_Out):
|
|
206
|
+
clean: TokenRow
|
|
207
|
+
corrupt: TokenRow
|
|
208
|
+
aligned: bool
|
|
209
|
+
differs: list[int]
|
|
210
|
+
labels: dict[str, int]
|
|
211
|
+
answer: AnswerTokens
|
|
212
|
+
distractor: AnswerTokens
|
|
213
|
+
issues: list[PromptIssue]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# -- analyses ----------------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TopToken(_Out):
|
|
220
|
+
token: str
|
|
221
|
+
id: int
|
|
222
|
+
prob: float
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class BaselinePrompt(_Out):
|
|
226
|
+
index: int
|
|
227
|
+
clean: str
|
|
228
|
+
corrupt: str
|
|
229
|
+
answer: str
|
|
230
|
+
distractor: str
|
|
231
|
+
clean_logit_diff: float | None
|
|
232
|
+
corrupt_logit_diff: float | None
|
|
233
|
+
clean_answer_prob: float | None
|
|
234
|
+
corrupt_answer_prob: float | None
|
|
235
|
+
clean_top: list[TopToken]
|
|
236
|
+
corrupt_top: list[TopToken]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class BaselineSummaryOut(_Out):
|
|
240
|
+
clean_logit_diff: float | None
|
|
241
|
+
corrupt_logit_diff: float | None
|
|
242
|
+
gap: float | None
|
|
243
|
+
clean_prefers_answer: int
|
|
244
|
+
corrupt_prefers_answer: int
|
|
245
|
+
clean_answer_prob: float | None
|
|
246
|
+
corrupt_answer_prob: float | None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class BaselineReport(_Out):
|
|
250
|
+
n: int
|
|
251
|
+
prompts: list[BaselinePrompt]
|
|
252
|
+
issues: list[PromptIssue]
|
|
253
|
+
summary: BaselineSummaryOut | None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class AttentionData(_Out):
|
|
257
|
+
layer: int
|
|
258
|
+
head: int
|
|
259
|
+
which: Literal["clean", "corrupt"]
|
|
260
|
+
index: int
|
|
261
|
+
tokens: list[str]
|
|
262
|
+
labels: dict[str, int]
|
|
263
|
+
pattern: list[list[float]]
|
|
264
|
+
average: list[list[float]]
|
|
265
|
+
average_tokens: list[str | None]
|
|
266
|
+
average_labels: dict[str, int]
|
|
267
|
+
n_average: int
|
|
268
|
+
n_total: int
|
|
269
|
+
length: int
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class PredictionLayer(_Out):
|
|
273
|
+
layer: int
|
|
274
|
+
top: list[TopToken]
|
|
275
|
+
answer_prob: float
|
|
276
|
+
distractor_prob: float
|
|
277
|
+
logit_diff: float
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class PredictionReport(_Out):
|
|
281
|
+
index: int
|
|
282
|
+
position: int
|
|
283
|
+
tokens: list[str]
|
|
284
|
+
settings: PredictionSettings
|
|
285
|
+
layers: list[PredictionLayer]
|
|
286
|
+
batch_members: list[int]
|
|
287
|
+
answer: str
|
|
288
|
+
distractor: str
|
|
289
|
+
issues: list[PromptIssue]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class Presets(_Out):
|
|
293
|
+
presets: list[dict[str, Any]]
|
|
294
|
+
suggestions: list[str]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class ArchitectureOut(_Out):
|
|
298
|
+
n_layers: int
|
|
299
|
+
n_heads: int
|
|
300
|
+
d_model: int
|
|
301
|
+
d_mlp: int
|
|
302
|
+
d_vocab: int
|
|
303
|
+
n_ctx: int
|
|
304
|
+
architecture: str
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class MemoryEstimateOut(_Out):
|
|
308
|
+
device: str
|
|
309
|
+
dtype: str
|
|
310
|
+
n_params: int
|
|
311
|
+
weights: int
|
|
312
|
+
activations: int
|
|
313
|
+
margin: int
|
|
314
|
+
total: int
|
|
315
|
+
available: int
|
|
316
|
+
verdict: Literal["fits", "tight", "wont_fit"]
|
|
317
|
+
explanation: str
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class EstimateOut(_Out):
|
|
321
|
+
id: str
|
|
322
|
+
revision: str
|
|
323
|
+
architecture: ArchitectureOut
|
|
324
|
+
download_bytes: int
|
|
325
|
+
total_bytes: int
|
|
326
|
+
gated: bool
|
|
327
|
+
# Whether TransformerLens can load this architecture, and if not, why (before downloading).
|
|
328
|
+
supported: bool
|
|
329
|
+
support_note: str | None
|
|
330
|
+
estimate: MemoryEstimateOut
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# -- runs --------------------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class RunDetail(_Out):
|
|
337
|
+
"""A run's files as stored. They were validated when written; older files are passed through."""
|
|
338
|
+
|
|
339
|
+
id: str
|
|
340
|
+
spec: dict[str, Any] | None
|
|
341
|
+
summary: dict[str, Any] | None
|
|
342
|
+
manifest: dict[str, Any] | None
|
|
343
|
+
predictions: PredictionReport | None = None
|
|
344
|
+
listing: RunListing | None
|
|
345
|
+
folder: str
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class PromptEvidence(_Out):
|
|
349
|
+
index: int
|
|
350
|
+
clean: str | None
|
|
351
|
+
corrupt: str | None
|
|
352
|
+
answer: str | None
|
|
353
|
+
distractor: str | None
|
|
354
|
+
effect: float | None
|
|
355
|
+
delta: float | None
|
|
356
|
+
patched_logit_diff: float | None
|
|
357
|
+
receiver_logit_diff: float | None
|
|
358
|
+
reference_logit_diff: float | None
|
|
359
|
+
patched_answer_prob: float | None
|
|
360
|
+
receiver_answer_prob: float | None
|
|
361
|
+
flipped: bool
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class SiteDetail(_Out):
|
|
365
|
+
run_id: str
|
|
366
|
+
site: SiteSummary
|
|
367
|
+
receiver: Literal["clean", "corrupt"]
|
|
368
|
+
reference: Literal["clean", "corrupt"]
|
|
369
|
+
metric: dict[str, Any]
|
|
370
|
+
statistics: dict[str, Any]
|
|
371
|
+
prompts: list[PromptEvidence]
|
|
372
|
+
strongest: list[int]
|
|
373
|
+
weakest: list[int]
|
|
374
|
+
dataset_changed: bool
|
|
375
|
+
dataset_available: bool
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class ComparisonChange(_Out):
|
|
379
|
+
label: str
|
|
380
|
+
kind: str
|
|
381
|
+
layer: int
|
|
382
|
+
head: int | None
|
|
383
|
+
feature: int | None = None
|
|
384
|
+
position_key: str
|
|
385
|
+
variant_key: str | None = None
|
|
386
|
+
index_a: int
|
|
387
|
+
index_b: int
|
|
388
|
+
row: int
|
|
389
|
+
col: int
|
|
390
|
+
effect_a: dict[str, float | None]
|
|
391
|
+
effect_b: dict[str, float | None]
|
|
392
|
+
rank_a: int
|
|
393
|
+
rank_b: int
|
|
394
|
+
flags: list[Literal["sign", "left_top", "entered_top"]]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class DiffCell(_Out):
|
|
398
|
+
index_a: int
|
|
399
|
+
row: int
|
|
400
|
+
col: int
|
|
401
|
+
value: float
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class SpecDifference(_Out):
|
|
405
|
+
path: str
|
|
406
|
+
a: Any
|
|
407
|
+
b: Any
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class Comparison(_Out):
|
|
411
|
+
run_a: str
|
|
412
|
+
run_b: str
|
|
413
|
+
n_common: int
|
|
414
|
+
n_a: int
|
|
415
|
+
n_b: int
|
|
416
|
+
spearman: float | None
|
|
417
|
+
top_k: int
|
|
418
|
+
top_overlap: int
|
|
419
|
+
top_a: list[str]
|
|
420
|
+
top_b: list[str]
|
|
421
|
+
changes: list[ComparisonChange]
|
|
422
|
+
flagged: list[int]
|
|
423
|
+
n_sign_changes: int
|
|
424
|
+
diff: list[DiffCell]
|
|
425
|
+
same_layout: bool
|
|
426
|
+
spec_differences: list[SpecDifference]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Local server security.
|
|
2
|
+
|
|
3
|
+
* The server binds to 127.0.0.1 only.
|
|
4
|
+
* Every HTTP and WebSocket request must carry the session token: as the HttpOnly cookie set when
|
|
5
|
+
the launch URL (``/?token=...``) is opened, or as an ``Authorization: Bearer`` header.
|
|
6
|
+
* The browser is opened with a single-use launch code instead of the token, because a browser's
|
|
7
|
+
command line can be read by other users of the machine. The URL printed in the terminal carries
|
|
8
|
+
the token itself, for opening more tabs or another browser.
|
|
9
|
+
* The Host header must name this server (defeats DNS rebinding). Requests that carry an Origin
|
|
10
|
+
must come from this server's origin; WebSocket handshakes must carry one, and so must
|
|
11
|
+
cookie-authenticated requests that change anything (or a Fetch Metadata header). Fetch Metadata
|
|
12
|
+
headers must say same-origin. No CORS headers are sent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hmac
|
|
18
|
+
import secrets
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
25
|
+
|
|
26
|
+
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
27
|
+
# The launch URL. /api/session also works behind the Vite dev proxy.
|
|
28
|
+
LOGIN_PATHS = {"/", "/api/session"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def new_token() -> str:
|
|
32
|
+
return secrets.token_urlsafe(32)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SecurityConfig:
|
|
37
|
+
token: str
|
|
38
|
+
port: int
|
|
39
|
+
dev_origins: list[str] = field(default_factory=list)
|
|
40
|
+
_launch: tuple[str, float] | None = field(default=None, repr=False)
|
|
41
|
+
_launch_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def cookie_name(self) -> str:
|
|
45
|
+
return f"logogram_{self.port}"
|
|
46
|
+
|
|
47
|
+
def allowed_hosts(self) -> set[str]:
|
|
48
|
+
hosts = {f"127.0.0.1:{self.port}", f"localhost:{self.port}", f"[::1]:{self.port}"}
|
|
49
|
+
for origin in self.dev_origins:
|
|
50
|
+
hosts.add(urlsplit(origin).netloc)
|
|
51
|
+
return hosts
|
|
52
|
+
|
|
53
|
+
def allowed_origins(self) -> set[str]:
|
|
54
|
+
origins = {f"http://{h}" for h in self.allowed_hosts()}
|
|
55
|
+
origins.update(self.dev_origins)
|
|
56
|
+
return origins
|
|
57
|
+
|
|
58
|
+
def token_ok(self, candidate: str | None) -> bool:
|
|
59
|
+
return bool(candidate) and hmac.compare_digest(candidate.encode(), self.token.encode())
|
|
60
|
+
|
|
61
|
+
def new_launch_code(self, ttl: float = 600.0) -> str:
|
|
62
|
+
"""A code that logs in once, within ``ttl`` seconds, for the URL the browser is opened
|
|
63
|
+
with."""
|
|
64
|
+
code = secrets.token_urlsafe(24)
|
|
65
|
+
with self._launch_lock:
|
|
66
|
+
self._launch = (code, time.monotonic() + ttl)
|
|
67
|
+
return code
|
|
68
|
+
|
|
69
|
+
def use_launch_code(self, candidate: str) -> bool:
|
|
70
|
+
with self._launch_lock:
|
|
71
|
+
if self._launch is None or not candidate:
|
|
72
|
+
return False
|
|
73
|
+
code, expires = self._launch
|
|
74
|
+
if time.monotonic() > expires:
|
|
75
|
+
self._launch = None
|
|
76
|
+
return False
|
|
77
|
+
if not hmac.compare_digest(candidate.encode(), code.encode()):
|
|
78
|
+
return False
|
|
79
|
+
self._launch = None # once only
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _header(scope: Scope, name: bytes) -> str | None:
|
|
84
|
+
for key, value in scope.get("headers", []):
|
|
85
|
+
if key == name:
|
|
86
|
+
return value.decode("latin-1")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cookies(scope: Scope, name: str) -> list[str]:
|
|
91
|
+
"""Every cookie called ``name``. Cookies aren't separated by port, so a page served from
|
|
92
|
+
another local port can add one with the same name; it mustn't hide the real one."""
|
|
93
|
+
values = []
|
|
94
|
+
for key, value in scope.get("headers", []):
|
|
95
|
+
if key != b"cookie":
|
|
96
|
+
continue
|
|
97
|
+
for part in value.decode("latin-1").split(";"):
|
|
98
|
+
k, _, v = part.strip().partition("=")
|
|
99
|
+
if k == name:
|
|
100
|
+
values.append(v)
|
|
101
|
+
return values
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _query_token(scope: Scope) -> str | None:
|
|
105
|
+
query = scope.get("query_string", b"").decode("latin-1")
|
|
106
|
+
for part in query.split("&"):
|
|
107
|
+
key, _, value = part.partition("=")
|
|
108
|
+
if key == "token":
|
|
109
|
+
return value
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SecurityMiddleware:
|
|
114
|
+
"""Pure ASGI middleware so it covers HTTP and WebSocket alike."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, app: ASGIApp, config: SecurityConfig) -> None:
|
|
117
|
+
self.app = app
|
|
118
|
+
self.config = config
|
|
119
|
+
|
|
120
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
121
|
+
if scope["type"] not in ("http", "websocket"):
|
|
122
|
+
await self.app(scope, receive, send)
|
|
123
|
+
return
|
|
124
|
+
cfg = self.config
|
|
125
|
+
host = _header(scope, b"host")
|
|
126
|
+
if host not in cfg.allowed_hosts():
|
|
127
|
+
await self._deny(scope, receive, send, 421, "Unknown host.")
|
|
128
|
+
return
|
|
129
|
+
origin = _header(scope, b"origin")
|
|
130
|
+
method = scope.get("method", "GET")
|
|
131
|
+
needs_origin = scope["type"] == "websocket" or method not in SAFE_METHODS
|
|
132
|
+
if origin is not None and origin not in cfg.allowed_origins():
|
|
133
|
+
await self._deny(scope, receive, send, 403, "Cross-origin requests are not allowed.")
|
|
134
|
+
return
|
|
135
|
+
if needs_origin and origin is None and scope["type"] == "websocket":
|
|
136
|
+
await self._deny(scope, receive, send, 403, "Missing origin.")
|
|
137
|
+
return
|
|
138
|
+
# Fetch Metadata: refuse requests started by another site, including other local ports
|
|
139
|
+
# (which count as the same *site*, so SameSite cookies alone don't stop them).
|
|
140
|
+
fetch_site = _header(scope, b"sec-fetch-site")
|
|
141
|
+
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
|
|
142
|
+
await self._deny(scope, receive, send, 403, "Cross-site requests are not allowed.")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Opening the launch URL exchanges the token (or the single-use launch code) for a
|
|
146
|
+
# cookie, then drops it from the URL.
|
|
147
|
+
query_token = _query_token(scope)
|
|
148
|
+
if scope["type"] == "http" and query_token is not None and scope["path"] in LOGIN_PATHS:
|
|
149
|
+
if cfg.token_ok(query_token) or cfg.use_launch_code(query_token):
|
|
150
|
+
await self._login(send)
|
|
151
|
+
else:
|
|
152
|
+
await self._deny(scope, receive, send, 401, _LOGIN_HELP)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
bearer = _header(scope, b"authorization")
|
|
156
|
+
bearer_token = bearer[7:] if bearer and bearer.lower().startswith("bearer ") else None
|
|
157
|
+
if cfg.token_ok(bearer_token):
|
|
158
|
+
await self.app(scope, receive, send)
|
|
159
|
+
return
|
|
160
|
+
if any(cfg.token_ok(c) for c in _cookies(scope, cfg.cookie_name)):
|
|
161
|
+
# A browser sends the cookie by itself, so a request that changes something must
|
|
162
|
+
# show where it came from (every current browser sends one of these).
|
|
163
|
+
if needs_origin and origin is None and fetch_site is None:
|
|
164
|
+
await self._deny(scope, receive, send, 403, "Missing origin.")
|
|
165
|
+
return
|
|
166
|
+
await self.app(scope, receive, send)
|
|
167
|
+
return
|
|
168
|
+
await self._deny(scope, receive, send, 401, _LOGIN_HELP)
|
|
169
|
+
|
|
170
|
+
async def _login(self, send: Send) -> None:
|
|
171
|
+
cookie = f"{self.config.cookie_name}={self.config.token}; HttpOnly; SameSite=Strict; Path=/"
|
|
172
|
+
await send(
|
|
173
|
+
{
|
|
174
|
+
"type": "http.response.start",
|
|
175
|
+
"status": 303,
|
|
176
|
+
"headers": [
|
|
177
|
+
(b"location", b"/"),
|
|
178
|
+
(b"set-cookie", cookie.encode("latin-1")),
|
|
179
|
+
(b"cache-control", b"no-store"),
|
|
180
|
+
(b"referrer-policy", b"no-referrer"),
|
|
181
|
+
],
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
await send({"type": "http.response.body", "body": b""})
|
|
185
|
+
|
|
186
|
+
async def _deny(
|
|
187
|
+
self, scope: Scope, receive: Receive, send: Send, status: int, text: str
|
|
188
|
+
) -> None:
|
|
189
|
+
if scope["type"] == "websocket":
|
|
190
|
+
await send({"type": "websocket.close", "code": 4401 if status == 401 else 4403})
|
|
191
|
+
return
|
|
192
|
+
body = text.encode("utf-8")
|
|
193
|
+
await send(
|
|
194
|
+
{
|
|
195
|
+
"type": "http.response.start",
|
|
196
|
+
"status": status,
|
|
197
|
+
"headers": [
|
|
198
|
+
(b"content-type", b"text/plain; charset=utf-8"),
|
|
199
|
+
(b"content-length", str(len(body)).encode()),
|
|
200
|
+
(b"cache-control", b"no-store"),
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
await send({"type": "http.response.body", "body": body})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_LOGIN_HELP = (
|
|
208
|
+
"Logogram needs its session token.\n\n"
|
|
209
|
+
"Open the link printed in the terminal where you started `logogram` "
|
|
210
|
+
"(it ends with ?token=...). Each start creates a new token, and the link the browser was "
|
|
211
|
+
"first opened with works only once.\n"
|
|
212
|
+
)
|