Repository navigation
Expand file tree
/
Copy paththreat-model.yaml
More file actions
2461 lines (2440 loc) · 225 KB
/
Copy paththreat-model.yaml
File metadata and controls
2461 lines (2440 loc) · 225 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# threat-model.yaml — derived index; the prose threat model (threat-model.md) is canonical.
# Authority order: prose > yaml > json. Regenerate whenever the prose changes: CI fails a change to the prose that
# changes neither this file nor threat-model.json (ci/validate_threat_model.py --base).
schema: threat-model-sidecar/v2
project: lens
# The prose in the same commit, not a hash of it: a hash is one line every change to the prose edits, so any two pull
# requests touching the prose conflicted on it (#507). `git hash-object threat-model.md` names the exact prose.
prose_version: "threat-model.md@same-commit"
model_status: under-review
triage_policy: strict
confidence: {documented: 223, maintainer: 46, inferred: 91, assumption: 8}
generation:
model: "Claude Opus 5.5 (claude-opus-5-5)"
effort: default
plugins:
- threat-model
- threat-model-recon
- threat-model-surface
- threat-model-interview
- threat-model-authoring
- threat-model-backtest
- threat-model-sidecar
x-roles:
orchestrator: "Claude Opus 5.5 (claude-opus-5-5)"
authoring: "Claude Opus 5.5 (claude-opus-5-5)"
revision: "Claude Opus 5.5 (claude-opus-5-5)"
backtest: "Claude Sonnet 5.5 (claude-sonnet-5-5)"
recon: "Claude Sonnet 5.5 (claude-sonnet-5-5)"
surface: "Claude Sonnet 5.5 (claude-sonnet-5-5)"
sidecar: "Claude Opus 5.5 (claude-opus-5-5)"
x-plugin-version: "threat-model 1.2.1"
x-modeled-commit: 5f799f2ff8bb8a762d8151d9b10b535a17878b55
x-model-date: "2026-10-05"
x-repository: "https://github.com/adeelahmad/lense"
# ---------------------------------------------------------------- §1.2 / §1.3 / §1.4
components:
- name: web
scope: in
x-family: "Web app & API gateway (Web)"
reachability_precondition: "reachable from a browser request and changes what the API decides or what another person's browser runs"
provenance: {kind: inferred, question_id: Q34}
- name: identity
scope: in
x-family: "Identity & sessions (Identity)"
reachability_precondition: "reachable with no account, or with an account, and yields a session, token or admin the attacker was not given; setup-code findings apply only while no account exists"
provenance: {kind: documented, source: 'docs/authentication.md "one-time **setup code**"'}
- name: authz
scope: in
x-family: "Authorization & data API (AuthZ)"
reachability_precondition: "lets a principal read or change more than its role and the asset's access level allow, or open media without a link the server signed for it"
provenance: {kind: documented, source: 'SECURITY.md "What counts"'}
- name: outbound
scope: in
x-family: "Outbound fetchers & stored secrets (Outbound)"
reachability_precondition: "a URL a non-admin sets, or a guarded admin operand, reaches a non-public address; or a stored secret reaches anyone but the host the admin set"
provenance: {kind: maintainer, date: "2026-10"}
- name: ingest
scope: in
x-family: "Ingest & processing (Ingest)"
reachability_precondition: "reachable from bytes an editor uploads or a watched source delivers, and the fault is in how Lens runs the tool (arguments, environment, timeouts, sandbox flags)"
provenance: {kind: inferred, question_id: Q34}
- name: query
scope: in
x-family: "Query engines (Query)"
reachability_precondition: "query text from a non-admin reads outside the caller's namespaces, writes, fetches, or reaches SurrealQL"
provenance: {kind: inferred, question_id: Q34}
- name: assistant
scope: in
x-family: "Assistant & extensions (Assistant)"
reachability_precondition: "a non-admin (or content steering the model for one) exceeds the person's reads, changes data without approval outside notes and admin setup, or runs code on the host"
provenance: {kind: inferred, question_id: Q34}
- name: optional
scope: in
x-family: "Optional services, off by default (Optional)"
reachability_precondition: "the service is turned on by an admin, and the attacker lacks the login, network or token the feature requires"
provenance: {kind: inferred, question_id: Q34}
- name: ci-tooling-and-samples
scope: out
reason: "examples/ (source clone only), overrides/ (mkdocs theme), ci/, .github/, .pre-commit* (CI, in no image) and prod-frontend-deploy.yml (stale Vercel workflow) are unsupported paths built into no running service (§1.3)"
provenance: {kind: assumption, question_id: Q34}
- name: inert-backend-files
scope: out
reason: "fastapi_backend/tests/, watcher.py and commands/ are copied into the backend image but never started by the production command; a report must show production code reaching them (§1.3)"
provenance: {kind: assumption, question_id: Q34}
# ---------------------------------------------------------------- §1.5
host_side_effects:
- effect: network
stance: conditional
components: [identity, outbound, assistant, optional]
conditions: "outbound connections only to configured sources, model servers, mail, notification and telemetry targets; telemetry off by default; no update check; components.auto (on by default) runs pip installs and fetches hashed model files; llama.cpp and cloudflared from GitHub 'latest' when those features are on; hf: models from Hugging Face"
provenance: {kind: maintainer, date: "2026-10"}
- effect: x-other-outbound-calls
stance: absent
components: [web, identity, authz, outbound, ingest, query, assistant, optional]
conditions: "none found in Lens code outside the configured destinations"
provenance: {kind: assumption, question_id: Q19}
- effect: x-browser-map-tiles
stance: conditional
components: [web]
conditions: "the Library's Map fetches its tiles from maps.tile_url (OpenStreetMap's by default) in the member's browser while it is open; empty fetches nothing (N-MAP-TILES)"
provenance: {kind: documented, source: 'docs/configuration.md "The tile server sees which tiles are asked for"'}
- effect: x-ml-extra-weight-downloads
stance: conditional
components: [ingest]
conditions: "some optional ML extras download weights on first use on their own (Hugging Face, ModelScope)"
provenance: {kind: inferred, question_id: Q19}
- effect: environment-reads
stance: conditional
components: [outbound]
conditions: "plain urllib callers honour HTTP(S)_PROXY; the guarded openers ignore it"
provenance: {kind: assumption, question_id: Q19}
- effect: child-processes
stance: present
components: [ingest, outbound, assistant, optional]
conditions: "rclone, ffmpeg/ffprobe, LibreOffice, Chromium, Tesseract, poppler tools, antiword/catdoc, cloudflared, llama-server, unshare + Python for code tools, uv/pip; all as argument lists"
provenance: {kind: assumption, question_id: Q20}
- effect: network-sockets
stance: conditional
components: [web, outbound, optional]
conditions: "API and web app listen; a netguard proxy on loopback during captures; MQTT 1883 and syslog 5514 on sensors.bind (default 0.0.0.0) when sensors are on, and MQTT over WebSockets on 9883 with sensors.mqtt_web; llama-server on its port (0.0.0.0 in containers, with its own API key)"
provenance: {kind: assumption, question_id: Q19}
- effect: x-log-credentials
stance: present
components: [identity]
conditions: "the setup code and a /setup?code= link at WARNING; sign-in and reset links when mail is not set"
provenance: {kind: documented, source: 'docs/authentication.md "the API prints in its log"'}
- effect: filesystem
stance: present
components: [authz, outbound, ingest]
conditions: "everything under data_dir, including secret.key (mode 600) when ARCHIVE_SECRET_KEY is unset; temp dirs for imports, rclone config, LibreOffice and Chromium profiles"
provenance: {kind: inferred, question_id: Q19}
- effect: global-state
stance: present
components: [query, optional]
conditions: "rdflib SPARQL_LOAD_GRAPHS = False set on each SPARQL call; sys.path gains data_dir/python (components); a signal handler in the CLI"
provenance: {kind: assumption, question_id: Q19}
- effect: x-secrets-to-children
stance: conditional
components: [optional]
conditions: "tunnel token passed to cloudflared in its environment, not its command line (promoted to P-CHILD-SECRETS)"
provenance: {kind: documented, source: 'docs/remote-access.md "in its environment, not on its command line"'}
# ---------------------------------------------------------------- §1.7 trust table
entry_points:
- id: "every /api/v1/* route"
component: identity
parameters:
- name: "Authorization: Bearer (web JWT, la_ API token, lo_ app token)"
attacker_controllable: true
control_kinds: [data, serialized-state]
obligation_id: keep-bearer-secret
caller_must_enforce: "holder keeps it secret; Lens checks signature or hash, expiry, account, session"
provenance: {kind: documented, source: 'docs/authentication.md "Every request checks that the account is still active"'}
- id: "every request"
component: web
parameters:
- name: "Host header"
attacker_controllable: true
control_kinds: [resource-name]
obligation_id: list-served-hosts
caller_must_enforce: "operator lists served hosts"
provenance: {kind: documented, source: 'docs/configuration.md "checks the `Host` header"'}
- id: "IP groups, throttles"
component: authz
parameters:
- name: "X-Forwarded-For, peer address"
attacker_controllable: true
x-condition: "honoured only from trusted proxies"
control_kinds: [x-trusted-header]
obligation_id: trusted-proxy-chain
caller_must_enforce: "a reverse proxy sets the header; trusted_proxies lists exactly the front hops"
provenance: {kind: documented, source: 'docs/configuration.md "Without it, a visitor can claim any address"'}
- id: "passkey origin, OAuth discovery, external-login redirect"
component: identity
parameters:
- name: "X-Forwarded-Host, X-Forwarded-Proto"
attacker_controllable: true
x-condition: "honoured for served hosts or from trusted proxies"
control_kinds: [resource-name]
obligation_id: trust-proxy-headers-behind-proxy
caller_must_enforce: "TRUST_PROXY_HEADERS only behind a real proxy"
provenance: {kind: documented, source: 'docs/authentication.md "anyone could choose the address"'}
- id: "POST /auth/login, /iiif/auth/access form"
component: identity
parameters:
- name: "email, password"
attacker_controllable: true
control_kinds: [data, size, rate]
caller_must_enforce: "none — P-PASSWORDS-OFF (passwords work only where auth.passwords is on)"
provenance: {kind: documented, source: 'docs/authentication.md "With passwords off, password sign-in"'}
- id: "/auth/setup, /auth/passkey/setup*, /auth/setup/no-passkey"
component: identity
parameters:
- name: "setup code, email, name"
attacker_controllable: true
control_kinds: [data]
obligation_id: private-log-until-first-admin
caller_must_enforce: "keep the log private until the first admin exists"
provenance: {kind: documented, source: 'docs/configuration.md "so a stranger who finds a new"'}
- id: "/auth/refresh, /auth/ticket, /auth/signin-link/*, /auth/password/*, WebAuthn flows"
component: identity
parameters:
- name: "rotating and one-time secrets"
attacker_controllable: true
control_kinds: [data]
obligation_id: keep-one-time-secrets
caller_must_enforce: "holder keeps them secret"
provenance: {kind: documented, source: 'docs/authentication.md "A link lasts three days"'}
- id: "POST /auth/passkeys/elsewhere"
component: identity
parameters:
- name: "an address to take your sign-in to"
attacker_controllable: true
x-condition: "signed in themselves; not API tokens or apps"
control_kinds: [resource-name]
caller_must_enforce: "only an address Lens opens at (tunnel, HTTPS domains); 15 minutes, once, other links left alone"
provenance: {kind: documented, source: 'docs/authentication.md "Lens opens at another address"'}
- id: "/oauth/register, /authorize, /token, /revoke"
component: identity
parameters:
- name: "redirect URIs, client metadata, code, verifier, refresh token"
attacker_controllable: true
x-condition: "registration is anonymous; authorize runs in the victim's browser"
control_kinds: [data, resource-name]
obligation_id: oauth-client-state-and-consent
caller_must_enforce: "the app keeps state and its verifier; the person consents"
provenance: {kind: documented, source: 'docs/authentication.md "Apps register themselves"'}
- id: "/auth/external/{key}/*"
component: identity
parameters:
- name: "next, state, code, provider profile"
attacker_controllable: true
x-condition: "profile only as far as the provider allows"
control_kinds: [data, x-collaborator-data]
obligation_id: admin-chooses-providers
caller_must_enforce: "admin chooses providers"
provenance: {kind: documented, source: 'docs/authentication.md "when the provider vouches for the email"'}
- id: "POST /sources/rclone-signin, /sources/rclone-signin/{id} (signing a source in through rclone)"
component: identity
parameters:
- name: "type, params, address (state, code, error), the token rclone prints"
attacker_controllable: true
x-condition: "admins start it; while it waits (ten minutes, one at a time) rclone listens on 127.0.0.1:53682 on the server, so anything running there can reach it, and the pasted address is passed on only to that port with only its state, code and error, when the state is this sign-in's"
control_kinds: [data, x-collaborator-data]
caller_must_enforce: "the person pastes only the address their own sign-in ended on; the host keeps 127.0.0.1:53682 to itself"
provenance: {kind: documented, source: 'docs/processing.md "Lens passes only that address''s state, code and"'}
- id: "/mcp"
component: identity
parameters:
- name: "bearer, Origin, JSON-RPC body"
attacker_controllable: true
control_kinds: [data, size, rate]
caller_must_enforce: "none — P-OAUTH"
provenance: {kind: documented, source: 'docs/mcp.md "A token an app asked for another server"'}
- id: "namespace, recording and child-object routes"
component: authz
parameters:
- name: "namespace name, rid, child ids (sequential integers)"
attacker_controllable: true
control_kinds: [resource-name]
caller_must_enforce: "none — P-NS-ISOLATION (Lens checks the role in each handler)"
provenance: {kind: documented, source: 'docs/api.md "Namespaces you can''t read answer 404"'}
- id: "routes not read one by one (about 370)"
component: authz
parameters:
- name: "per route"
attacker_controllable: true
x-condition: "per gate"
control_kinds: [x-per-route]
obligation_id: follow-gate-pattern
caller_must_enforce: "follow the gate pattern"
provenance: {kind: inferred, question_id: Q8}
- id: "media, report, embed, IIIF content"
component: authz
parameters:
- name: "exp, sig, full, ?s= share token, /s/<code> short code"
attacker_controllable: true
control_kinds: [serialized-state]
obligation_id: keep-link-private
caller_must_enforce: "holder keeps the link private"
provenance: {kind: documented, source: 'SECURITY.md "only the server''s own signed links should work"'}
- id: "/iiif/auth/token, /probe, /logout"
component: authz
parameters:
- name: "origin, messageId, IIIF cookie, probe token"
attacker_controllable: true
x-condition: "any site can frame the token page"
control_kinds: [resource-name]
obligation_id: set-iiif-allowed-origins
caller_must_enforce: "operator sets iiif.allowed_origins"
provenance: {kind: documented, source: 'docs/iiif.md "limits which viewer sites can get tokens"'}
- id: "/recordings, /search, /public/search, chat retrieval"
component: query
parameters:
- name: "query text, filters, sort, limit, offset"
attacker_controllable: true
x-condition: "/public/search is anonymous"
control_kinds: [data, size]
caller_must_enforce: "none — P-NO-INJECTION"
provenance: {kind: documented, source: 'SECURITY.md "injection into the database queries"'}
- id: "/graph/query, MCP graph_query"
component: query
parameters:
- name: "Cypher text, params"
attacker_controllable: true
x-condition: "any signed-in person"
control_kinds: [callback-code]
caller_must_enforce: "none — P-QUERY"
provenance: {kind: documented, source: 'docs/graph.md "3 million steps"'}
- id: "/namespaces/{name}/sparql"
component: query
parameters:
- name: "SPARQL text"
attacker_controllable: true
x-condition: "namespace readers"
control_kinds: [callback-code]
caller_must_enforce: "none — P-NO-FETCH"
provenance: {kind: documented, source: 'routes/rdf.py sparql docstring "SERVICE and FROM aren''t allowed"'}
- id: "/namespaces/{name}/rdf/import"
component: query
parameters:
- name: "Turtle, N-Triples, JSON-LD"
attacker_controllable: true
x-condition: "editors"
control_kinds: [serialized-state, size]
caller_must_enforce: "none — P-NO-FETCH"
provenance: {kind: documented, source: 'rdf_import.py docstring "RDF/XML isn''t read"'}
- id: "/uploads, /import, /recordings/{rid}/files"
component: ingest
parameters:
- name: "file bytes, name, extension, size"
attacker_controllable: true
x-condition: "editors"
control_kinds: [data, type-class, size]
caller_must_enforce: "none — P-UPLOAD"
provenance: {kind: documented, source: 'docs/configuration.md "Uploads accept only the types"'}
- id: "POST /hooks/import"
component: ingest
parameters:
- name: "bearer (a hook's lih_ token, or a person's la_, lo_ or web token with ?namespace=), file bytes, URL, text"
attacker_controllable: true
x-condition: "anyone holding a hook's token, for its namespace; a person's token, for namespaces where its person has the import permission and only with write scope"
control_kinds: [data, resource-name, size]
caller_must_enforce: "none — P-UPLOAD; the address form /hooks/import/{token} takes hook tokens only, so a person's token never sits in a logged path; ?for= hands a PDF back only for a document the same hook brought in that still waits for one (hand_back, off by default), and it becomes that document's rendition"
provenance: {kind: documented, source: 'docs/api.md "the address form takes hook tokens only"'}
- id: "watched folders, storage sources, IMAP, Gmail, iCal, news feeds"
component: outbound
parameters:
- name: "remote file names, bytes, mail, events, feed posts"
attacker_controllable: true
x-condition: "whoever writes to the remote, mails the box or serves the feed"
control_kinds: [data, resource-name, size]
obligation_id: admin-creates-sources
caller_must_enforce: "admin creates sources"
provenance: {kind: inferred, question_id: Q17}
- id: "Google Calendar sources"
component: outbound
parameters:
- name: "service account key file or connected account's refresh token, calendar ids, impersonate; the events Google returns"
attacker_controllable: true
x-condition: "key and token: admins only, sealed and write-only; events: anyone who can invite the account to an event or edit a calendar it reads"
control_kinds: [data, x-credential, x-prompt-injection, size]
obligation_id: admin-creates-sources
caller_must_enforce: "admin creates sources; read-only calendar.readonly scope, or the broader scope an admin already delegated to a service account acting as someone (google_auth.COVERS), kept in memory and used only for the narrower scope's calls; Google's own token endpoint and API address (a key file's token_uri is ignored); event text escaped into its .ics without control characters"
provenance: {kind: documented, source: 'docs/processing.md "The token endpoint is always Google''s own"'}
- id: "News feed sources"
component: outbound
parameters:
- name: "the feed's address (or a site's page that names its feed), user and password; the feed's XML and its posts' HTML"
attacker_controllable: true
x-condition: "address and password: admins only, sealed and write-only; XML and HTML: whoever serves the feed or writes its posts"
control_kinds: [data, x-prompt-injection, size]
obligation_id: admin-creates-sources
caller_must_enforce: "admins add them (web app, API, MCP subscribe_feed with a write-scope token, or the assistant's tool, which asks first outside a setup conversation or once it has read outside text); fetched as iCal feeds are; feedparser reads the XML with external entities off and drops scripts, styles and event handlers from a post's HTML; each post kept as an Atom entry Lens writes, without the feed's address; post text is marked untrusted in prompts"
provenance: {kind: documented, source: 'docs/processing.md "News feeds (RSS and Atom)"'}
- id: "Connect with Google"
component: outbound
parameters:
- name: "OAuth client id and secret; on the way back state, code and the email in Google's id token"
attacker_controllable: true
x-condition: "client: admins only; state and code: anyone who can send the admin a link"
control_kinds: [data, x-credential, x-collaborator-data]
obligation_id: admin-creates-sources
caller_must_enforce: "admin only, from the web app (API tokens refused), still an admin on return; single-use hashed state bound to the starting browser (cookie), 10 minutes, PKCE S256; client secret sealed on the flow, refresh token on the source; kept only when the source's scope was granted (read only, but all of Gmail over IMAP, all of Drive for Drive set to full access); Drive keeps its client's secret sealed too; returns only to the starting web origin"
provenance: {kind: documented, source: 'docs/processing.md "the consent itself is a person''s"'}
- id: "POST /imports/web, extension http tools, workflow HTTP call nodes, notification targets"
component: outbound
parameters:
- name: "URL"
attacker_controllable: true
x-condition: "editors (web, tools), owners (targets), admins (workflow nodes, host written out)"
control_kinds: [resource-name, x-network-destination]
caller_must_enforce: "none — P-SSRF-GUARD"
provenance: {kind: maintainer, date: "2026-10"}
- id: "the Library's map: GET /resources?located=true&bbox=..., GET /resources/map-tiles, MCP list_recordings located and bbox, maps.tile_url"
component: authz
parameters:
- name: "a bounding box (four numbers); the tile address and its credit (admins)"
attacker_controllable: true
x-condition: "bbox: any member, parsed as four numbers in range (400 otherwise) and bound as query parameters; tile address: admins only (Settings, Maps)"
control_kinds: [data, x-network-destination]
caller_must_enforce: "none; a place is listed only to members of its asset's namespace or collection, as GET /resources/{rid} already gives it; tiles are fetched by each viewer's browser, never by Lens, so the tile server sees the area looked at (N-MAP-TILES); Leaflet shows the credit as HTML, so the web app escapes it"
provenance: {kind: documented, source: 'docs/processing.md "the tile server sees the area being looked at"'}
- id: "the assistant's web_search and web_read"
component: assistant
parameters:
- name: "a search query and a page address, both chosen by the model; the results and pages that come back"
attacker_controllable: true
x-condition: "off until an admin picks a provider (web.search); anyone who may use the assistant, and indirectly whatever it read, through the model; results and pages: whoever runs the site"
control_kinds: [resource-name, x-network-destination, x-prompt-injection, size]
caller_must_enforce: "none — queries go only to the admin's provider; pages are fetched through netguard like web import, 10 MB at most (P-SSRF-GUARD); once the answer has read outside text, a query or address neither the person nor a tool gave it asks first (P-INJECTION-EGRESS); results and pages reach the model marked as data; the ledger keeps the provider or host, never the query or page"
provenance: {kind: documented, source: 'docs/assistant.md "a search or a page read sends words out of Lens"'}
- id: "the assistant's connection tools"
component: assistant
parameters:
- name: "a connection's id or name, a Gmail search, a message id, a time span, a path; the mail, events and files that come back; the message, event or text file the model would send or write"
attacker_controllable: true
x-condition: "admins who use the assistant, and indirectly whatever it read, through the model; mail and events: anyone who can email the account or invite it; files: whoever writes to the storage"
control_kinds: [resource-name, x-prompt-injection, size]
caller_must_enforce: "none — admins only, and only the tools for the connections there are; reading changes nothing in the account; a file is read up to 20 MB and a message's or file's text up to 11,000 characters; mail, events and files reach the model marked as data, and once read, tools that could carry it out of Lens ask first (P-INJECTION-EGRESS); bringing anything into a namespace waits for the person's yes; sending mail, saving a draft, adding an event and writing a file always wait for the person's yes, even in a setup conversation, with all of it shown (and a note when the answer read outside text), and the model is told never to act on what it read; sending and adding work only on a Gmail or Google Calendar connection an admin let do so (write, which asks Google for gmail.compose or calendar.events), checked again when approved; at most 50 addresses a field, 50,000 characters a message and 200,000 a file; a file is replaced only when asked and approved, and Google Drive connected read only is never written; each change is in the audit log; the ledger keeps what was called, never what came back"
provenance: {kind: documented, source: 'docs/assistant.md "Mail, calendars and files"'}
- id: "email replies"
component: assistant
parameters:
- name: "an imported email's headers and text (From, Reply-To, Message-ID, References, the body), read to draft a reply"
attacker_controllable: true
x-condition: "anyone who can send mail to a watched mailbox; From and Reply-To are forgeable"
control_kinds: [data, x-prompt-injection, size]
caller_must_enforce: "drafting needs the right to process the email; the email reaches the model marked untrusted and the system prompt says not to follow it; only the email's own namespace is searched for excerpts; a web address in the draft that neither the email nor the excerpts hold is broken up, and a citation to an excerpt the model wasn't given is taken out; nothing is sent: a draft waits for the person, who sees its recipient (the address Reply-To names) before anything goes"
provenance: {kind: documented, source: 'docs/email-replies.md "Lens never sends a reply on its own"'}
- id: "event proposals from dates said"
component: assistant
parameters:
- name: "a recording, a line and which of its dates; the fields to use instead of the prefill (title, start, end, where, description, attendees, invite, connection, calendar); the line's text and an email's addresses that fill the rest"
attacker_controllable: true
x-condition: "admins with a write token or session; the line's text and addresses: whoever wrote the asset (an email's sender, a speaker)"
control_kinds: [data, x-prompt-injection, size]
caller_must_enforce: "none; admins only, for an asset they can read, and only with a Google Calendar connection an admin let add events; the proposal is an approval of add_event with all of the event shown (the line, the time read from it, who is invited and that they aren't emailed unless invite is true), and nothing is added or sent until the person approves it signed in to Lens: an API key, an app, MCP and the assistant can only decline it; declining does nothing; the connection is checked again when approved; no model is called; at most 20 addresses are prefilled and 50 given; each proposal and each addition is in the audit log (calendar.propose, calendar.add)"
provenance: {kind: documented, source: "docs/processing.md \"Propose an event\""}
- id: "admin settings (model, embeddings, speech, decisions, Fedora, bridge, telemetry, IIIF import, rclone remotes, IMAP, Gmail sign-in, iCal, news feeds, MQTT bridge, OIDC issuer, web search, LanguageTool server)"
component: outbound
parameters:
- name: "URL, credentials, api_key_env"
attacker_controllable: false
x-condition: "admin only; documented guards still apply (P-ADMIN-GUARD)"
control_kinds: [resource-name, x-credential]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted"
provenance: {kind: maintainer, date: "2026-10"}
- id: "replies from remote servers"
component: outbound
parameters:
- name: "web page HTML and scripts, IIIF manifests, model replies, OIDC discovery, calendars and their redirects"
attacker_controllable: true
control_kinds: [data, object-topology]
obligation_id: treat-remote-replies-untrusted
caller_must_enforce: "treat as untrusted"
provenance: {kind: inferred, question_id: Q17}
- id: "chat, /graph/ask, MCP tools"
component: assistant
parameters:
- name: "prompt text; model-chosen tool calls"
attacker_controllable: true
x-condition: "also indirectly through content"
control_kinds: [data, x-prompt-injection]
caller_must_enforce: "none — P-RETRIEVAL; what people say to Lens reaches automations as outside text (chat.message, lens.untrusted)"
provenance: {kind: documented, source: 'docs/assistant.md "limited to what the person can read"'}
- id: "Lens extension side panel"
component: assistant
parameters:
- name: "answers shown there (model text that can repeat third-party text), the sign-in coming back"
attacker_controllable: true
x-condition: "indirectly through content"
control_kinds: [data, x-prompt-injection]
caller_must_enforce: "none; answers render as Markdown cleaned by DOMPurify (no scripts, images or forms; only http and https links), and the sign-in checks its state and PKCE"
provenance: {kind: documented, source: 'extensions/browser/src/core/assistant.js answerHtml docstring "cleaned of anything that could run or fetch on its own"'}
- id: "extensions (prompt, graph and http tools, hooks), custom nodes including template nodes"
component: assistant
parameters:
- name: "tool spec, graph JSON, template text"
attacker_controllable: true
x-condition: "any writer, for their own; sharing is gated; template-node reach is Q23"
control_kinds: [callback-code]
obligation_id: enable-only-trusted-extensions
caller_must_enforce: "person enables only extensions they trust"
provenance: {kind: documented, source: 'docs/assistant.md "other people''s run only when an admin shared them"'}
- id: "MCP servers: POST /extensions/mcp/read and /mcp/test, GET /extensions/mcp/presets, saving an extension of kind mcp, PATCH /extensions/{id}/tools, MCP mcp_servers and add_mcp_server"
component: assistant
parameters:
- name: "config text (JSON or YAML), a server's address and headers, or a command, its arguments and env; a tool name and its switches"
attacker_controllable: false
x-condition: "admins only, except reading a config, which any signed-in person may and which reaches nothing"
control_kinds: [data, x-network-destination, x-credential, callback-code]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted with the address and the command; reading a config parses text only; a command runs only in the MCP runner; credential values are sealed to the extension and where they go and shown as ••••; a tool switched off isn't offered, and every MCP tool asks first after outside text; a server whose spec says keep reuses one session for every caller"
provenance: {kind: documented, source: 'docs/assistant.md "Adding an MCP server"'}
- id: "Python code tools, shipped and admin-created templates, workflows, pipelines, routines"
component: assistant
parameters:
- name: "Python, Jinja source, regexes"
attacker_controllable: false
x-condition: "admin only"
control_kinds: [callback-code]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted"
provenance: {kind: maintainer, date: "2026-10"}
- id: "POST /automations/hooks/{token} (an automation's webhook)"
component: optional
parameters:
- name: "body (JSON, a form's text fields or text), query, Standard Webhooks headers, token in the path"
attacker_controllable: true
x-condition: "anyone given the address, once an automation with a webhook trigger is saved"
control_kinds: [data, size, rate, x-credential, x-prompt-injection]
obligation_id: keep-hook-addresses-secret
caller_must_enforce: "the person keeps the address and secret to the services that call it; Lens keeps the token hashed, caps size and rate, checks signatures sent, and treats what was sent as outside text"
provenance: {kind: documented, source: 'docs/automations.md "No webhook exists until an automation with such a trigger is saved"'}
x-tests:
- "fastapi_backend/tests/api/test_automation_hooks.py::test_a_hook_is_made_for_a_trigger_and_only_its_hash_kept"
- "fastapi_backend/tests/api/test_automation_hooks.py::test_a_call_is_a_webhook_received_activity"
- "fastapi_backend/tests/api/test_automation_hooks.py::test_size_and_rate_caps"
- "fastapi_backend/tests/api/test_automation_hooks.py::test_signed_calls_and_retries"
- "fastapi_backend/tests/api/test_automations.py::test_a_webhook_trigger_gets_its_address_when_saved_and_loses_it_when_dropped"
- id: "GET /feeds/public/{token}, /feeds/public/{token}/items/{item}/audio (a published feed, or a saved collection's podcast feed)"
component: optional
parameters:
- name: "token in the path, Range, If-None-Match"
attacker_controllable: true
x-condition: "anyone given the address, once an editor of a namespace makes a feed or someone who may read a saved collection asks for its feed"
control_kinds: [x-credential, resource-name]
obligation_id: keep-feed-addresses-secret
caller_must_enforce: "the person shares the address as a password; Lens finds the token by its hash, stops the old address when a new one is made, and lists only what the namespace's readers can read, or for a collection's feed what its person may read now"
provenance: {kind: documented, source: 'docs/feeds.md "Private by default"'}
- id: "POST /circles and /circles/shares, MCP save_circle and share_with_circle, the assistant's and automations' share_with_circle (circles and sharing with them)"
component: authz
parameters:
- name: "circle members, an activity or resource id, a note, with_item"
attacker_controllable: true
x-condition: "anyone signed in; the note and the snapshot reach the recipients"
control_kinds: [data, resource-name, size, x-prompt-injection]
obligation_id: share-only-with-circles
caller_must_enforce: "members must be people the sharer may add; Lens checks the sharer can read the item, refuses vaults, caps the note, and needs publish to give the item; the assistant asks first"
provenance: {kind: documented, source: 'docs/circles.md "What keeps it safe"'}
- id: "POST /circles/when-free, MCP when_free, the assistant's when_free (when people in your circles are free)"
component: authz
parameters:
- name: "people, a window, a time zone, minutes, the hours of the day"
attacker_controllable: true
x-condition: "anyone signed in"
control_kinds: [resource-name, size]
obligation_id: free-busy-only-when-let
caller_must_enforce: "the person asked must have let a circle with the asker see; Lens answers only start and end times, caps the window and the people, and records each ask in the asked person's timeline"
provenance: {kind: documented, source: 'docs/circles.md "When they''re free"'}
- id: "POST /circles/ask, MCP ask_their_agent, the assistant's ask_their_agent (asking someone's agent a question)"
component: assistant
parameters:
- name: "the person asked, a question"
attacker_controllable: true
x-condition: "anyone signed in; the question reaches a model"
control_kinds: [data, size, x-prompt-injection]
obligation_id: ask-agent-only-when-let
caller_must_enforce: "the person asked must have let a circle with the asker ask and named the namespaces; Lens answers from those only, wraps the question as outside text, and records the question and answer in their timeline"
provenance: {kind: documented, source: 'docs/circles.md "Ask their agent"'}
- id: "POST /ap/users/<name>/inbox, GET /ap/users/<name>, /.well-known/webfinger (circles with other servers)"
component: ingest
parameters:
- name: "an ActivityPub activity and its HTTP Signature, a handle"
attacker_controllable: true
x-condition: "anyone on the internet once federation is on"
control_kinds: [data, size, x-prompt-injection, auth]
obligation_id: federation-signed-from-a-circle
caller_must_enforce: "Lens answers 404 until an admin and the person turn it on, and takes a Note only signed by someone in one of their circles with the key Lens kept, recent, its digest matching; its text is outside text"
provenance: {kind: documented, source: 'docs/federation.md "What travels"'}
- id: "automations' params and prompts, POST /automations/preview and /automations/variables"
component: optional
parameters:
- name: "template text, a draft automation, an activity id"
attacker_controllable: true
x-condition: "anyone signed in (preview, the picker); the editors who manage an automation (its params)"
control_kinds: [callback-code, data, x-prompt-injection]
obligation_id: sandbox-automation-templates
caller_must_enforce: "Lens renders them in the Jinja sandbox (2 MB of output, guarded * and **; Q23); previews read only activities, namespaces and automations the person may see, and prompts mark outside text as data"
provenance: {kind: documented, source: 'docs/automations.md "The variable picker and preview"'}
- id: "MQTT 1883, syslog 5514, POST /sensors/push/{token}/{stream}"
component: optional
parameters:
- name: "payloads, hub logins, token in the path"
attacker_controllable: true
x-condition: "once enabled"
control_kinds: [data, size, rate, x-credential, x-prompt-injection]
obligation_id: enable-and-firewall-sensors
caller_must_enforce: "admin enables; operator firewalls; readings, alert patterns and new devices reach automations as outside text (lens.untrusted)"
provenance: {kind: documented, source: 'docs/sensors.md "Sensors are off until an admin turns them on"'}
- id: "NFS share (port 12049 on the host, 2049 in the worker)"
component: optional
parameters:
- name: "NFS and MOUNT RPCs, client address"
attacker_controllable: true
x-condition: "once enabled, from nfs.allowed_networks"
control_kinds: [data, x-network-source]
obligation_id: enable-and-limit-nfs
caller_must_enforce: "admin enables and picks the account; operator firewalls"
provenance: {kind: documented, source: 'docs/file-shares.md "NFS has no login"'}
- id: "SMB share (port 1445 on the host, 4445 in the worker)"
component: optional
parameters:
- name: "SMB2 requests, NTLM sign-in with a file access key, client address"
attacker_controllable: true
x-condition: "once enabled, from smb.allowed_networks"
control_kinds: [data, x-credential, x-network-source]
obligation_id: enable-and-limit-smb
caller_must_enforce: "admin enables; operator firewalls"
provenance: {kind: documented, source: 'docs/smb.md "What keeps it safe"'}
- id: "web app /mqtt and port 9883 (MQTT over WebSockets, relayed by lens-relay.js ahead of Next)"
component: optional
parameters:
- name: "the WebSocket handshake, MQTT packets in binary messages, hub logins"
attacker_controllable: true
x-condition: "once sensors.enabled, sensors.mqtt and sensors.mqtt_web; from the internet through the tunnel or HTTPS"
control_kinds: [data, size, rate, x-credential, x-network-source]
caller_must_enforce: "admin enables; a hub login on every connection (never anonymous); failed logins throttled per visitor from X-Forwarded-For as trusted proxies report it; no cookie, so no cross-site WebSocket hijacking"
provenance: {kind: documented, source: 'docs/sensors.md "At the web app''s address"'}
- id: "web app /s3 and /dav (file shares relayed by lens-relay.js, ahead of Next)"
component: optional
parameters:
- name: "any method, path, Host and body, passed to the share servers with X-Forwarded-Prefix and X-Forwarded-For"
attacker_controllable: true
x-condition: "once shares.enabled and shares.remote; from the internet through the tunnel or HTTPS"
control_kinds: [x-credential, resource-name, x-network-source]
caller_must_enforce: "admin enables both; per-person access keys, files added imported as at the ports (shares.add_files); failed WebDAV sign-ins throttled per visitor from X-Forwarded-For as trusted proxies report it"
provenance: {kind: documented, source: 'docs/file-tree.md "At the web app''s address"'}
- id: "POST /api/v1/mesh/join (a machine joining the mesh)"
component: optional
parameters:
- name: "a join token's id, a nonce, the machine's name and Nebula public key, an HMAC of those keyed with the token's secret, facts about the machine"
attacker_controllable: true
x-condition: "anyone reaching the web app, once a token exists"
control_kinds: [x-credential, data]
caller_must_enforce: "no sign-in: the token's secret never crosses the wire, the HMAC is checked in constant time, wrong proofs throttled per address (8 in 15 minutes), a token is spent on use and expires, and the answer is sealed with a key derived from the secret"
provenance: {kind: documented, source: 'docs/mesh.md "Joining"'}
- id: "/api/v1/mesh/node/config, /node/renew, /node/beat (a node's own calls, with its node token)"
component: optional
parameters:
- name: "a public key to certify; a status report: whether Nebula runs, its peers, its machine, its log lines"
attacker_controllable: true
x-condition: "a machine that joined (a removed one is refused)"
control_kinds: [x-credential, data, x-peer]
caller_must_enforce: "the node token is 256 random bits kept only as a hash; a node gets its own config and a certificate for its own address and groups only; reports are capped; they arrive at the primary's mesh address, which the API's host check admits for these paths alone while the mesh is on"
provenance: {kind: documented, source: 'docs/mesh.md "After joining"'}
- id: "GET /api/v1/mesh/node/worker, /node/media/{id} (a worker node's database user and its jobs' files, with its node token)"
component:
10BC8
optional
parameters:
- name: "a recording id; the node's role and namespaces"
attacker_controllable: true
x-condition: "a worker node that joined (a removed one is refused)"
control_kinds: [x-credential, data, x-peer]
caller_must_enforce: "worker nodes only (403 for others); the database user is the node's own, of Lens's database only, never the server's root, removed with the node and made again with a new password on an undo; files are those of the namespaces the node serves, never a vault's, sent plain over the mesh; the archive's secret never leaves the primary"
provenance: {kind: documented, source: 'docs/mesh.md "Worker nodes"'}
- id: "PUT /api/v1/mesh/node/sandbox (a compute node's sandbox gateway's client certificate, with its node token), and Lens's calls to that gateway on the node's mesh address, port 7443"
component: optional
parameters:
- name: "a CA certificate, a client certificate and its key, PEM"
attacker_controllable: true
x-condition: "a compute node that joined (a removed one is refused)"
control_kinds: [x-credential, x-peer]
caller_must_enforce: "compute nodes only (403 for others); each part capped and loaded as a certificate or key (400 otherwise); the key kept sealed like a setting's secret; Lens reaches the gateway over the mesh only (7443 open to the primary only, relayed to the gateway by the node's mesh container), with mutual TLS checked against the name the gateway's certificate was made for; the node gets nothing of Lens's"
provenance: {kind: documented, source: 'docs/mesh.md "Compute nodes"'}
- id: "POST /api/v1/mesh/tokens, PATCH and DELETE /api/v1/mesh/nodes/{id}, PUT /api/v1/settings/mesh, MCP and assistant mesh tools"
component: optional
parameters:
- name: "a node's role, name, namespaces, token hours and uses; the mesh's network, port, hosts, cipher"
attacker_controllable: false
x-condition: "admin only (MCP: with the write scope)"
control_kinds: [x-env-config, x-credential]
caller_must_enforce: "admin is trusted; audited (mesh.token.create, mesh.join, mesh.node.*); the network can't change once machines have addresses in it; the assistant's adding and removing a node always wait for the person's approval; an install line (it holds a join token's secret) goes only to the MCP agent that asked or to the person who approved, once, and is never kept in an approval, the audit log or the model's context; the read tools never show a token's secret"
provenance: {kind: documented, source: 'docs/mesh.md "Settings"'}
- id: "UDP 4242 on the primary and ports 8000 and 8080 on its mesh address (the mesh container, while the mesh is on)"
component: optional
parameters:
- name: "Nebula handshakes and tunnelled packets from anyone reaching the port; on the mesh, TCP to the database and the API"
attacker_controllable: true
x-condition: "anyone reaching UDP 4242; on the mesh, a machine with a certificate the mesh's CA signed"
control_kinds: [data, x-peer]
caller_must_enforce: "Nebula drops what no certificate of the mesh's CA signs and anything on the blocklist; its firewall, rendered by Lens, lets only lens-worker reach 8000 and lens-node 8080; the relays listen on the mesh address only; the container is the only one holding NET_ADMIN, makes its own key pair (the private key stays in its own volume) and runs only Nebula's release with the checksum kept in Lens"
provenance: {kind: documented, source: 'docs/mesh.md "On this machine"'}
- id: ".env, archive.yaml, LENS_*, ARCHIVE_SECRET_KEY, RCLONE_BINARY"
component: outbound
parameters:
- name: "process configuration"
attacker_controllable: false
x-condition: "operator"
control_kinds: [x-env-config]
obligation_id: protect-process-config
caller_must_enforce: "keep unreadable to others"
provenance: {kind: maintainer, date: "2026-10"}
- id: "workers and DB peers"
component: authz
parameters:
- name: "DB credential, shared database rows"
attacker_controllable: false
x-condition: "trusted peer"
control_kinds: [x-peer]
obligation_id: trusted-peer-hosts
caller_must_enforce: "run peers on trusted hosts"
provenance: {kind: inferred, question_id: Q9}
- id: "host control inbox and the monitoring scrape token"
component: authz
parameters:
- name: "inbox/upgrade request: release ref (empty: the version running) with https=0|1, public=0|1 and target=full|lean, or action=services with profile=mcp|browser|monitoring and turn=on|off (or an older Lens's monitoring=on|off)"
attacker_controllable: true
x-condition: "writable by the containers and local users"
control_kinds: [x-env-config]
obligation_id: agent-allow-list
caller_must_enforce: "the agent installs only a release tag or the install's branch, changes only the HTTPS ports, the web app's network reach and the edition (other values dropped), and only toggles the mcp, browser and monitoring profiles (any other refused); a writer can at most restart Lens with those choices or open it to the network"
provenance: {kind: documented, source: "docs/updates.md"}
- name: "/metrics scrape token, .lens-control/prometheus-token"
attacker_controllable: true
x-condition: "anyone reaching the web app; the file is world-readable on the host"
control_kinds: [credential]
obligation_id: metrics-token
caller_must_enforce: "token compared in constant time; metrics carry no personal content"
provenance: {kind: documented, source: "docs/prometheus.md Privacy"}
- id: "admin machine setup requests"
component: authz
parameters:
- name: "PUT /api/v1/admin/install {public, target}, POST /api/v1/settings/https/ports {open}, MCP set_install_settings"
attacker_controllable: false
x-condition: "admin only (MCP: with the write scope)"
control_kinds: [x-env-config]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted; audited (lens.install, https.ports); reaches the machine only as an inbox request the agent checks again"
provenance: {kind: documented, source: 'docs/updates.md "Setting the machine up again"'}
- id: "admin optional container requests"
component: authz
parameters:
- name: "PUT /api/v1/admin/services/{profile} {on}, MCP set_service: profile mcp|browser|monitoring"
attacker_controllable: false
x-condition: "admin only (MCP: with the write scope)"
control_kinds: [x-env-config]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted; audited (lens.services, lens.monitoring); unknown profiles are a 404; one change at a time; reaches the machine only as an inbox request the agent checks again"
provenance: {kind: documented, source: 'docs/assistant.md "Optional containers"'}
- id: "admin auto scaling"
component: authz
parameters:
- name: "/api/v1/scaling (providers, groups, units, tick), PUT /api/v1/settings/scaling, MCP and assistant scaling tools: provider credentials, a group's terms and rules, a unit id"
attacker_controllable: false
x-condition: "admin only (MCP: with the write scope)"
control_kinds: [x-credential, x-env-config]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted; off until turned on; audited (scaling.*) and in the activity ledger; credentials sealed and shown only as set; a group launches nothing until a person signed in to the web app approves its terms (P-SCALING)"
provenance: {kind: documented, source: 'docs/scaling.md "The person approves"'}
- id: "admin sandbox gateway"
component: authz
parameters:
- name: "PUT /api/v1/admin/sandbox {on}, MCP set_sandbox_gateway"
attacker_controllable: false
x-condition: "admin only (MCP: with the write scope)"
control_kinds: [x-env-config]
obligation_id: admin-is-trusted
caller_must_enforce: "admin is trusted; audited (lens.sandbox); refused when preflight finds the machine can't run the gateway; reaches the machine only as an inbox request (action=services, profile=sandbox) the agent checks again; off deletes every sandbox first"
provenance: {kind: documented, source: 'docs/assistant.md "The gateway"'}
- id: "import runs"
component: authz
parameters:
- name: "GET /api/v1/import/runs, POST /api/v1/import/runs/{id}/undo; assistant import_runs, undo_import: a run id"
attacker_controllable: true
x-condition: "whoever started the run, owners of its namespace, admins; indirectly through content the model reads"
control_kinds: [data, x-prompt-injection]
caller_must_enforce: "none — a run is seen only by those (404 otherwise); undo deletes each asset through deletion.delete and needs the remove permission (or a collection admin role) on every one, else nothing is deleted (403); the assistant's undo always waits for the person's approval; audited per asset (recording.delete) and for the run (import.undo)"
provenance: {kind: documented, source: 'docs/api.md "Import runs"'}
- id: "entity and topic curation tools"
component: authz
parameters:
- name: "MCP edit_entity, edit_topic, merge_topics, delete_topic, undo_topic_change, undo_graph_change; assistant propose_topic_change: entity and topic ids, names, labels, definitions"
attacker_controllable: true
x-condition: "an agent with an editor's write-scope token; indirectly through content the model reads"
control_kinds: [data, x-prompt-injection]
caller_must_enforce: "none — the curate permission in each namespace touched and a write-capable principal (the web app's own route functions); the assistant's changes wait for the person's approval; merges and deletes are kept to undo, and audited"
provenance: {kind: documented, source: 'docs/mcp.md "curate entities and topics as an editor does"'}
- id: "automations"
component: assistant
parameters:
- name: "/api/v1/automations (save, try, run now, decide, draft from a sentence), the MCP and assistant automation tools: triggers, where and If match trees, the event workflow graph (action params are Jinja templates), policy, limits, an activity id; the text of the activities an automation runs on"
attacker_controllable: true
x-condition: "any editor, for automations in namespaces they edit; indirectly through activity text (mail, titles, chat) that reaches action params"
control_kinds: [callback/code, data, x-prompt-injection]
caller_must_enforce: "none — it runs as its maker with their permissions at run time (P-AUTOMATION-ACTOR); actions reaching outside Lens ask first; outside text marks intents untrusted and breaks up links (P-INJECTION-EGRESS); chains and bursts are bounded (P-AUTOMATION-LOOP); `matches` patterns refuse nested repeats and repeated alternatives that start alike, read at most 20,000 characters and give up after 50 ms"
provenance: {kind: documented, source: 'docs/automations.md "Running automations"'}
x-tests:
- "fastapi_backend/tests/domain/test_match_ops.py::test_checks_refuse_values_a_test_cant_use"
- "fastapi_backend/tests/domain/test_match_ops.py::test_a_pattern_reads_a_long_value_only_so_far_and_gives_up_when_slow"
- id: "code sandbox"
component: assistant
parameters:
- name: "assistant run_python, run_bash, sandbox_files, sandbox_nodes, share_sandbox_file and import_sandbox_file: code the model writes, attachment ids, a path in the sandbox, a compute node's id or name; sandbox.* settings (admins)"
attacker_controllable: true
x-condition: "the people sandbox.who names (editors and admins by default); indirectly through content the model reads"
control_kinds: [callback/code, data, x-prompt-injection]
caller_must_enforce: "none — off by default; the code runs in the conversation's own OpenShell sandbox with none of Lens's data or keys and no network unless sandbox.network allows it (P-SANDBOX); with the network on, a run after outside text asks first (P-INJECTION-EGRESS); files in only from the person's attachments, out only through an approved import or as a download for that person; with compute nodes registered by the mesh, a run may go to a ready compute node's own gateway, reached only over the mesh, with the same sandbox settings, and only where the model passed node or the conversation's sandbox already is"
provenance: {kind: documented, source: 'docs/assistant.md "Code sandbox"'}
- id: "code sandbox shared files"
component: assistant
parameters:
- name: "GET /api/v1/chats/{cid}/files/{fid}: a file share_sandbox_file copied out of the conversation's sandbox; its bytes come from code the model wrote"
attacker_controllable: true
x-condition: "the person whose conversation it is; indirectly through content the model reads"
control_kinds: [data, x-prompt-injection]
caller_must_enforce: "none — served only to the conversation's person (404 otherwise, or for another conversation's id) as an attachment with nosniff and a sandbox content policy, never rendered; random 96-bit id checked against the conversation; kept AES-256-GCM encrypted with a key derived from the server's secret, bound to the file and conversation; at most 20 a conversation, 25 MB each; deleted with the conversation or the account"
provenance: {kind: documented, source: 'docs/assistant.md "Code sandbox"'}
- id: "lens help tool"
component: authz
parameters:
- name: "assistant and MCP lens_help: a question's words, a docs page's name"
attacker_controllable: true
x-condition: "any signed-in person or agent with a read token; indirectly through content the model reads"
control_kinds: [data, x-prompt-injection]
caller_must_enforce: "none — it only reads: docs from a fixed folder (a page picked by name from the indexed ones, never opened by path) in an in-memory index; this server's facts carry no keys, tokens or model server addresses, and the settings pages only for admins"
provenance: {kind: documented, source: 'docs/assistant.md "Questions about Lens itself"'}
- id: "deep research"
component: assistant
parameters:
- name: "/research <goal>, research: true, POST /chats/{cid}/research, /research/{id} (revise, stop, resume, note, podcast), start_research (assistant and MCP), research_status (MCP): a goal, answers to follow-up questions, plan edits; research.* settings (admins)"
attacker_controllable: true
x-condition: "anyone who may use the assistant, in their own conversations; indirectly through what the run reads, through the model"
control_kinds: [data, x-prompt-injection, size]
caller_must_enforce: "none — the plan waits for the person's own approval (P-RESEARCH); the run uses only its sources' read tools, as the person, within its tool calls, minutes and budgets; its web searches and page reads don't ask again, bounded by the plan and the ledger; one run per person at a time; the report's claims are checked against what they cite"
provenance: {kind: documented, source: 'docs/assistant.md "Deep research"'}
- id: "microsandbox program requests"
component: authz
parameters:
- name: "ask/NAME.want in /data/.lens/programs: a program's name"
attacker_controllable: true
x-condition: "writable by the Lens user inside the sandbox"
control_kinds: [x-env-config]
obligation_id: program-allow-list
caller_must_enforce: "the start script installs only libreoffice or chromium (fixed Debian package lists), answers in a root-only folder, reads only the name"
provenance: {kind: documented, source: "docs/microsandbox.md LibreOffice and Chromium"}
# ---------------------------------------------------------------- §1.7 contract-dimension matrix
# Every in-scope component × the eight required dimensions appears exactly once.
# Prose sub-rows that refine a standard dimension (for example "Numeric domain:
# absolute session cap") are carried as x- dimensions so each standard cell
# stays unique. `x-routes-to` keeps the prose "Routes to" cell when it names
# more than the single property_id.
contract_dimensions:
# --- Web
- {component: web, dimension: numeric-domain, status: not-applicable, conditions: "bodies pass through; size caps are the API's", x-routes-to: "§1.7 rows", provenance: {kind: documented, source: 'backend-proxy.ts docstring "Serves the FastAPI backend''s paths on this origin"'}}
- {component: web, dimension: failure-atomicity, status: not-applicable, conditions: "no server state; only the encrypted session cookie", x-routes-to: "P-AUTH-REQ", provenance: {kind: documented, source: 'docs/configuration.md "encrypts the NextAuth session cookie"'}}
- {component: web, dimension: recursive-cyclic-topology, status: not-applicable, conditions: "stateless proxy, no recursive input; forwards bytes", provenance: {kind: inferred, question_id: Q34}}
- {component: web, dimension: concurrency-reentrancy, status: not-applicable, conditions: "stateless proxy; no shared mutable state", provenance: {kind: inferred, question_id: Q34}}
- {component: web, dimension: callback-execution, status: unresolved, conditions: "the API authorizes every call; proxy.ts only redirects pages", x-routes-to: "N-WEBGATE", question_id: Q8, provenance: {kind: inferred, question_id: Q8}}
- {component: web, dimension: serialization-reconstruction, status: claimed, property_id: P-AUTH-REQ, conditions: "session cookie encrypted with AUTH_SECRET (dev secret voids it)", provenance: {kind: documented, source: 'docs/authentication.md "takes effect immediately"'}}
- {component: web, dimension: reference-lifecycle, status: claimed, property_id: P-AUTH-REQ, conditions: "sign-out ends the session at once", provenance: {kind: documented, source: 'docs/authentication.md "takes effect immediately"'}}
- {component: web, dimension: resource-complexity, status: disclaimed, property_id: N-VOLUME, conditions: "no rate limit beyond sign-in throttles", provenance: {kind: documented, source: 'SECURITY.md "denial of service by sheer volume"'}}
- {component: web, dimension: x-browser-rendering, status: unresolved, conditions: "lower-role text rendered in another person's browser", question_id: Q24, provenance: {kind: inferred, question_id: Q24}}
- {component: web, dimension: x-headers, status: claimed, property_id: P-HARDENED, conditions: "web app page headers and API framing as documented", x-routes-to: "P-HARDENED, P-FRAME; others N-HEADERS", provenance: {kind: documented, source: 'docs/deployment.md "What''s hardened already"'}}
- {component: web, dimension: x-forwarded-host, status: unresolved, conditions: "which header chooses the address OAuth discovery, the passkey origin and external-login redirects name", question_id: Q38, provenance: {kind: inferred, question_id: Q38}}
# --- Identity
- {component: identity, dimension: numeric-domain, status: claimed, property_id: P-LIFETIMES, conditions: "token and link lifetimes and tokens.max_days", provenance: {kind: documented, source: 'docs/authentication.md "A link lasts three days"'}}
- {component: identity, dimension: x-numeric-domain-absolute-session-cap, status: unresolved, conditions: "the 168 h window resets on every refresh", question_id: Q4, provenance: {kind: inferred, question_id: Q4}}
- {component: identity, dimension: failure-atomicity, status: claimed, property_id: P-O
10BC8
NCE, conditions: "one-time secrets spent before use; a refused attempt spends nothing; one code makes one first admin", x-routes-to: "P-ONCE, P-SETUP-ONCE", provenance: {kind: documented, source: 'docs/authentication.md "works once"'}}
- {component: identity, dimension: recursive-cyclic-topology, status: not-applicable, conditions: "flat credentials; no nested identity input", provenance: {kind: inferred, question_id: Q34}}
- {component: identity, dimension: callback-execution, status: disclaimed, property_id: N-OIDC, conditions: "identity from the provider's token and userinfo endpoints", x-routes-to: "N-OIDC, N-PROVIDER", provenance: {kind: documented, source: 'docs/authentication.md "token and userinfo endpoints"'}}
- {component: identity, dimension: serialization-reconstruction, status: claimed, property_id: P-AUTH-REQ, conditions: "one key signs access tokens and media links", x-routes-to: "P-AUTH-REQ, P-MEDIA-LINK", provenance: {kind: documented, source: 'docs/configuration.md "signs access tokens and media links"'}}
- {component: identity, dimension: reference-lifecycle, status: claimed, property_id: P-AUTH-REQ, conditions: "sign-out, disable, revoke: immediate for every token kind; revoking a grant or token ends its tokens", provenance: {kind: documented, source: 'docs/authentication.md "stop working at once"'}}
- {component: identity, dimension: x-reference-lifecycle-credential-change, status: unresolved, conditions: "password change, passkey removal: API tokens, app grants, IIIF cookies survive", question_id: Q3, provenance: {kind: inferred, question_id: Q3}}
- {component: identity, dimension: x-revoke-race, status: unresolved, conditions: "a token minted while its grant is being revoked", question_id: Q39, provenance: {kind: inferred, question_id: Q39}}
- {component: identity, dimension: x-link-supersession, status: unresolved, conditions: "whether anyone but the owner or an admin can cancel a pending sign-in link", question_id: Q41, provenance: {kind: inferred, question_id: Q41}}
- {component: identity, dimension: x-redirect-target, status: unresolved, conditions: "post-sign-in next target", question_id: Q40, provenance: {kind: inferred, question_id: Q40}}
- {component: identity, dimension: concurrency-reentrancy, status: claimed, property_id: P-REUSE, conditions: "web refresh: replay within 60 s accepted; after that it ends the whole session", provenance: {kind: documented, source: 'docs/authentication.md "it ends the whole session"'}}
- {component: identity, dimension: x-concurrency-oauth-refresh, status: claimed, property_id: P-REUSE, conditions: "OAuth refresh: replay within 60 s refused, changes nothing; after that it ends the access", provenance: {kind: documented, source: 'docs/authentication.md "A swapped one that comes back within 60 seconds is refused"'}}
- {component: identity, dimension: resource-complexity, status: claimed, property_id: P-THROTTLE, conditions: "failed password sign-ins: 8 per 15 minutes; other anonymous routes are Q5", provenance: {kind: documented, source: 'docs/authentication.md "throttled per email and address"'}}
- {component: identity, dimension: x-resource-complexity-setup-routes, status: unresolved, conditions: "setup-code guesses", question_id: Q42, provenance: {kind: inferred, question_id: Q42}}
- {component: identity, dimension: x-enumeration, status: unresolved, conditions: "passkey 404, external-login messages, anonymous 401 vs 404", question_id: Q6, provenance: {kind: inferred, question_id: Q6}}
- {component: identity, dimension: x-mcp-audience, status: unresolved, conditions: "/mcp accepts tokens that name no server; the docs intro says MCP only reads", question_id: Q13, provenance: {kind: inferred, question_id: Q13}}
- {component: identity, dimension: x-csrf, status: claimed, property_id: P-BEARER-ONLY, conditions: "bearer-only API; IIIF cookie flow and NextAuth handlers excepted", provenance: {kind: documented, source: 'docs/architecture.md "so there is no CSRF surface"'}}
- {component: identity, dimension: x-forwarded-host, status: unresolved, conditions: "which header chooses the address OAuth discovery, the passkey origin and external-login redirects name", question_id: Q38, provenance: {kind: inferred, question_id: Q38}}
# --- AuthZ
- {component: authz, dimension: numeric-domain, status: claimed, property_id: MX-AUTHZ-LIST-CAPS, conditions: "/recordings at most 1000 rows; other lists capped per route; REST offset uncapped (N-VOLUME)", x-routes-to: "§1.7", provenance: {kind: documented, source: 'docs/api.md "at most 1000"'}}
- {component: authz, dimension: x-numeric-domain-mcp-tool-arguments, status: unresolved, conditions: "MCP tool offsets and page sizes (for example get_entity's mentions offset) have no documented maximum", question_id: Q47, provenance: {kind: inferred, question_id: Q47}}
- {component: authz, dimension: failure-atomicity, status: unresolved, conditions: "delete commits rows then removes files; move, merge, undo are stepwise", question_id: Q12, provenance: {kind: inferred, question_id: Q12}}
- {component: authz, dimension: recursive-cyclic-topology, status: claimed, property_id: P-HIERARCHY, conditions: "collections at most 8 deep, no cycles, 5000 per namespace", provenance: {kind: documented, source: 'docs/api.md "at most 8 deep"'}}
- {component: authz, dimension: callback-execution, status: not-applicable, conditions: "no caller code runs; authorization reads DB rows only", provenance: {kind: inferred, question_id: Q8}}
- {component: authz, dimension: serialization-reconstruction, status: claimed, property_id: P-MEDIA-LINK, conditions: "signature binds path, expiry and full; other query parameters unsigned", provenance: {kind: documented, source: 'core/security.py sign_path docstring "Extra query parameters are kept but not signed"'}}
- {component: authz, dimension: reference-lifecycle, status: disclaimed, property_id: N-SIGNED-BEARER, conditions: "signed links valid to expiry; not revocable; not bound to a person", provenance: {kind: documented, source: 'core/security.py sign_path docstring "grants read access to exactly this path until it expires"'}}
- {component: authz, dimension: x-reference-lifecycle-share-report-links, status: unresolved, conditions: "share and report links after the creator loses a role; after a move", question_id: Q10, provenance: {kind: inferred, question_id: Q10}}
- {component: authz, dimension: concurrency-reentrancy, status: unresolved, conditions: "roles load per request; caches key on the readable set", question_id: Q8, provenance: {kind: inferred, question_id: Q8}}
- {component: authz, dimension: resource-complexity, status: disclaimed, property_id: N-VOLUME, conditions: "no rate limit on read APIs", provenance: {kind: documented, source: 'SECURITY.md "denial of service by sheer volume"'}}
- {component: authz, dimension: x-access-parts, status: claimed, property_id: P-ACCESS-PARTS, conditions: "access levels, open parts, description", x-routes-to: "P-ACCESS-PARTS, N-PUBLIC", provenance: {kind: documented, source: 'docs/access.md "the parts of a public asset anyone may use"'}}
- {component: authz, dimension: x-client-address-compose, status: claimed, property_id: P-IPGROUP, conditions: "both compose files trust the web app container, so each visitor gets their own address", x-routes-to: "P-IPGROUP, P-THROTTLE", provenance: {kind: documented, source: 'docs/deployment.md "so each visitor gets their own"'}}
- {component: authz, dimension: x-client-address-other-variants, status: unresolved, conditions: "web app alone, installers, NAS, Proxmox without a reverse proxy setting X-Forwarded-For", question_id: Q2, provenance: {kind: inferred, question_id: Q2}}
- {component: authz, dimension: x-share-scope, status: unresolved, conditions: "?s= share tokens open the audio, video, frames and page API routes", question_id: Q7, provenance: {kind: inferred, question_id: Q7}}
# --- Outbound
- {component: outbound, dimension: numeric-domain, status: claimed, property_id: P-SSRF-GUARD, conditions: "web paths, iCal and news feeds: ports 80 and 443", x-routes-to: "P-SSRF-GUARD, P-ADMIN-GUARD", provenance: {kind: documented, source: 'docs/api.md "Only public addresses are reached, on ports 80 and 443"'}}
- {component: outbound, dimension: x-numeric-domain-notification-ports, status: unresolved, conditions: "notification targets may use any port; the guard checks addresses only", question_id: Q15, provenance: {kind: inferred, question_id: Q15}}
- {component: outbound, dimension: failure-atomicity, status: claimed, property_id: P-NOTIFY, conditions: "each notification event claimed once across processes, then retried", provenance: {kind: documented, source: 'docs/notifications.md "claimed once"'}}
- {component: outbound, dimension: recursive-cyclic-topology, status: claimed, property_id: P-SSRF-GUARD, conditions: "redirects: http and https only, each hop re-checked; credentials never follow a redirect to another server; notifications and an automation's http_request follow none", x-routes-to: "P-SSRF-GUARD, P-SECRETS", provenance: {kind: documented, source: 'docs/processing.md "never sent on to another server"'}}
- {component: outbound, dimension: callback-execution, status: disclaimed, property_id: N-CHROMIUM, conditions: "Chromium may lack its sandbox; the proxy is the barrier", provenance: {kind: documented, source: 'docs/deployment.md "what stops a page is Lens''s proxy"'}}
- {component: outbound, dimension: serialization-reconstruction, status: claimed, property_id: P-SECRETS, conditions: "sealed secrets are write-only through the API", provenance: {kind: documented, source: 'docs/configuration.md "the API reports whether one is set, never its value"'}}
- {component: outbound, dimension: x-admin-operand-guards, status: claimed, property_id: P-ADMIN-GUARD, conditions: "iCal and news feed public-address guard; telemetry endpoint without user:password or link-local address", provenance: {kind: documented, source: 'docs/processing.md "fetched the way web pages are captured"'}}
- {component: outbound, dimension: x-admin-operand-guards-telemetry-headers, status: unresolved, conditions: "saved headers dropped when the endpoint moves to another host", question_id: Q35, provenance: {kind: inferred, question_id: Q35}}
- {component: outbound, dimension: x-remote-identity-imap, status: claimed, property_id: P-REMOTE-NAMES, conditions: "a rebuilt mailbox gets new names, so an old name never reads another message", provenance: {kind: documented, source: 'docs/processing.md "an old one never reads another message"'}}
- {component: outbound, dimension: x-remote-identity-other-sources, status: unresolved, conditions: "rclone, iCal, news feed and IIIF cache keys and names", question_id: Q43, provenance: {kind: inferred, question_id: Q43}}
- {component: outbound, dimension: reference-lifecycle, status: unresolved, conditions: "key placement: secret.key in data_dir with data and backups", question_id: Q14, provenance: {kind: inferred, question_id: Q14}}
- {component: outbound, dimension: x-reference-lifecycle-key-rotation, status: claimed, property_id: MX-OUTBOUND-KEY-ROTATION, conditions: "a new key makes stored credentials and encrypted files unreadable", x-routes-to: "§1.13", provenance: {kind: documented, source: 'docs/deployment.md "Stable secrets"'}}
- {component: outbound, dimension: concurrency-reentrancy, status: unresolved, conditions: "loopback guard proxy open to local processes during a capture", question_id: Q19, provenance: {kind: inferred, question_id: Q19}}
- {component: outbound, dimension: resource-complexity, status: unresolved, conditions: "IIIF media up to 4 GB in memory; rclone copies uncapped", question_id: Q16, provenance: {kind: inferred, question_id: Q16}}
# --- Ingest
- {component: ingest, dimension: numeric-domain, status: claimed, property_id: P-UPLOAD, conditions: "uploads.max_mb, 512 MB disk floor, documents.max_pages", provenance: {kind: documented, source: 'docs/configuration.md "512 MB free"'}}
- {component: ingest, dimension: x-numeric-domain-duration-decompressed-size, status: unresolved, conditions: "no duration cap; docx XML read whole", question_id: Q22, provenance: {kind: inferred, question_id: Q22}}
- {component: ingest, dimension: recursive-cyclic-topology, status: unresolved, conditions: "emails inside emails, no stated depth cap", question_id: Q22, provenance: {kind: inferred, question_id: Q22}}
- {component: ingest, dimension: failure-atomicity, status: claimed, property_id: P-UPLOAD, conditions: "an upload file holds whole chunks only", provenance: {kind: documented, source: 'domain/uploads.py docstring "whole chunks"'}}
- {component: ingest, dimension: callback-execution, status: unresolved, conditions: "decoders run in the worker's process tree, unsandboxed", question_id: Q20, provenance: {kind: inferred, question_id: Q20}}
- {component: ingest, dimension: x-process-control, status: claimed, property_id: P-CONVERT-TIMEOUT, conditions: "a conversion or capture outliving its timeout, or children left alive after a kill", provenance: {kind: maintainer, date: "2026-10-06", question_id: Q20}}
- {component: ingest, dimension: serialization-reconstruction, status: claimed, property_id: P-UPLOAD, conditions: "nothing uploaded is executed; served as audio, video or download", provenance: {kind: documented, source: 'docs/configuration.md "uploaded is executed"'}}
- {component: ingest, dimension: reference-lifecycle, status: unresolved, conditions: "temp dirs and process groups cleaned after conversion, best effort", question_id: Q34, provenance: {kind: inferred, question_id: Q34}}
- {component: ingest, dimension: concurrency-reentrancy, status: unresolved, conditions: "jobs run at least once; a retried step may repeat side effects", question_id: Q12, provenance: {kind: inferred, question_id: Q12}}
- {component: ingest, dimension: resource-complexity, status: unresolved, conditions: "some decoders have no timeout; LibreOffice and Chromium get 300 s", question_id: Q22, provenance: {kind: inferred, question_id: Q22}}
- {component: ingest, dimension: x-demuxer-reads, status: unresolved, conditions: "ffmpeg runs without -protocol_whitelist; an upload that makes it read another local file", question_id: Q21, provenance: {kind: inferred, question_id: Q21}}
# --- Query
- {component: query, dimension: numeric-domain, status: claimed, property_id: P-QUERY, conditions: "Cypher: 3 million steps, 8 seconds", provenance: {kind: documented, source: 'docs/graph.md "3 million steps"'}}
- {component: query, dimension: recursive-cyclic-topology, status: claimed, property_id: P-QUERY, conditions: "variable-length paths up to 8 hops", provenance: {kind: documented, source: 'docs/graph.md "3 million steps"'}}
- {component: query, dimension: failure-atomicity, status: not-applicable, conditions: "read-only; engines never change data", x-routes-to: "P-QUERY", provenance: {kind: documented, source: 'domain/cypher.py docstring "it never changes data"'}}
- {component: query, dimension: callback-execution, status: claimed, property_id: P-NO-FETCH, conditions: "SPARQL SERVICE, FROM, graph loading refused", provenance: {kind: documented, source: 'routes/rdf.py sparql docstring "SERVICE and FROM aren''t allowed"'}}
- {component: query, dimension: serialization-reconstruction, status: claimed, property_id: P-NO-FETCH, conditions: "remote JSON-LD contexts refused; RDF/XML not read", provenance: {kind: documented, source: 'routes/rdf.py sparql docstring "SERVICE and FROM aren''t allowed"'}}
- {component: query, dimension: reference-lifecycle, status: not-applicable, conditions: "no saved query state; caches key on the readable set", provenance: {kind: inferred, question_id: Q8}}
# The prose tags this row (assumption, Q19). The v2 schema requires an
# `unresolved` row to carry inferred provenance, so it is recorded one step
# weaker (inferred never closes; assumption could close under relaxed).
- {component: query, dimension: concurrency-reentrancy, status: unresolved, conditions: "SPARQL sets a process-global rdflib flag per call", question_id: Q19, x-prose-provenance-kind: assumption, provenance: {kind: inferred, question_id: Q19}}
- {component: query, dimension: resource-complexity, status: unresolved, conditions: "SPARQL has a row cap only; the Cypher regex guard is heuristic", question_id: Q25, x-also-questions: [Q16], provenance: {kind: inferred, question_id: Q25}}
# --- Assistant
- {component: assistant, dimension: numeric-domain, status: claimed, property_id: P-RETRIEVAL, conditions: "tool loop capped by ai.max_steps (or a question's max_tool_calls, at most ai.max_tool_calls) and ai.max_transcript_reads", provenance: {kind: documented, source: 'docs/assistant.md "capped by"'}}
- {component: assistant, dimension: failure-atomicity, status: claimed, property_id: P-APPROVAL, conditions: "change tools become approval cards; approval re-checks the role; notes and setup excepted", provenance: {kind: documented, source: 'docs/assistant.md "Approvals"'}}
- {component: assistant, dimension: recursive-cyclic-topology, status: claimed, property_id: P-FLOW, conditions: "flow graphs never loop back; step budget", provenance: {kind: documented, source: 'domain/flow.py docstring "Graphs never loop back"'}}
- {component: assistant, dimension: callback-execution, status: claimed, property_id: P-RETRIEVAL, conditions: "tools see only the person's reads; Python tools from admins only", x-routes-to: "P-RETRIEVAL, P-NO-CODE", provenance: {kind: documented, source: 'docs/assistant.md "limited to what the person can read"'}}
- {component: assistant, dimension: serialization-reconstruction, status: claimed, property_id: P-NO-CODE, conditions: "manifests checked; Python refused for non-admins", provenance: {kind: documented, source: 'domain/extensions.py docstring "Only admins write Python"'}}
- {component: assistant, dimension: x-extension-scope-hooks, status: claimed, property_id: P-EXT-SCOPE, conditions: "another person's hook runs only when an admin shared it", provenance: {kind: documented, source: 'docs/assistant.md "other people''s run only when an admin shared them"'}}
- {component: assistant, dimension: x-extension-scope-tools, status: unresolved, conditions: "another person's tools in my assistant; whether http tool arguments can change its host", question_id: Q27, provenance: {kind: inferred, question_id: Q27}}
- {component: assistant, dimension: reference-lifecycle, status: unresolved, conditions: "saved answers keep text the person can no longer read", question_id: Q11, provenance: {kind: inferred, question_id: Q11}}
- {component: assistant, dimension: concurrency-reentrancy, status: not-applicable, conditions: "one loop per conversation; tool calls run in order", provenance: {kind: inferred, question_id: Q34}}
- {component: assistant, dimension: resource-complexity, status: disclaimed, property_id: N-BUDGET, conditions: "budgets are pre-run estimates, off by default", provenance: {kind: documented, source: 'docs/budgets.md "Budgets are off unless an admin sets one"'}}
- {component: assistant, dimension: x-prompt-injection, status: claimed, property_id: P-INJECTION-EGRESS, conditions: "steering the model within the person's tools is disclaimed (N-PROMPT); carrying what it read out through outbound tools, setup changes or chat-room links asks first or is broken up", provenance: {kind: documented, source: 'docs/assistant.md "it read out of Lens waits for your approval"'}}
- {component: assistant, dimension: x-template-reach, status: unresolved, conditions: "any writer can use a template node in a custom node", question_id: Q23, provenance: {kind: inferred, question_id: Q23}}
# --- Optional
- {component: optional, dimension: numeric-domain, status: unresolved, conditions: "payload 256 KB, 600 per minute per stream, syslog line 64 KB", question_id: Q29, provenance: {kind: inferred, question_id: Q29}}
- {component: optional, dimension: failure-atomicity, status: not-applicable, conditions: "flat, append-only readings stored as data; no multi-step state", provenance: {kind: inferred, question_id: Q34}}
- {component: optional, dimension: recursive-cyclic-topology, status: not-applicable, conditions: "flat readings; no nesting", provenance: {kind: inferred, question_id: Q34}}
- {component: optional, dimension: serialization-reconstruction, status: not-applicable, conditions: "readings stored as data; no object reconstruction", provenance: {kind: inferred, question_id: Q34}}
- {component: optional, dimension: callback-execution, status: claimed, property_id: P-CHILD-SECRETS, conditions: "tunnel token and llama-server key stay off command lines", provenance: {kind: documented, source: 'docs/remote-access.md "in its environment, not on its command line"'}}
- {component: optional, dimension: reference-lifecycle, status: claimed, property_id: P-SENSORS, conditions: "each service off until an admin turns it on", provenance: {kind: documented, source: 'docs/sensors.md "Sensors are off until an admin turns them on"'}}
- {component: optional, dimension: confidentiality, status: claimed, property_id: P-NFS, conditions: "NFS share off until an admin turns it on; adds files only as its account may import, never changes what Lens made (nfs.add_files off: read-only); one account's view; allowed networks only", provenance: {kind: documented, source: 'docs/file-shares.md "NFS has no login"'}}
- {component: optional, dimension: confidentiality, status: claimed, property_id: P-SMB, conditions: "SMB share off until an admin turns it on; adds files only as its owner may import, never changes what Lens made (smb.add_files off: read-only); each key sees its owner's view; allowed networks only", provenance: {kind: documented, source: 'docs/smb.md "What keeps it safe"'}}
- {component: optional, dimension: concurrency-reentrancy, status: disclaimed, property_id: N-VOLUME, conditions: "MQTT and syslog connection counts, one thread per connection", provenance: {kind: documented, source: 'SECURITY.md "denial of service by sheer volume"'}}
- {component: optional, dimension: resource-complexity, status: disclaimed, property_id: N-VOLUME, conditions: "MQTT and syslog connection counts, one thread per connection", provenance: {kind: documented, source: 'SECURITY.md "denial of service by sheer volume"'}}
# ---------------------------------------------------------------- §1.8
outputs:
- component: authz
channel: "JSON API responses (titles, assets' text, notes, metadata)"
taint: same-as-input
x-taint-note: "as typed by people or produced by ASR/OCR; search snippet is HTML-escaped with <mark>"
taint_provenance: {kind: documented, source: 'docs/api.md "an HTML-escaped `snippet`"'}
invariants: []
downstream_must_not_assume:
- {id: dmna-api-text-safe-for-sinks, statement: "that any text field is safe for HTML, Markdown, SQL or a shell", provenance: {kind: documented, source: 'docs/api.md "an HTML-escaped `snippet`"'}}
- component: authz
channel: "Signed links in responses (api/media.py)"
taint: constrained
taint_provenance: {kind: documented, source: 'docs/architecture.md "is never signed"'}
invariants:
- {id: inv-signed-links-allow-listed, statement: "server-written for allow-listed fields only; text that looks like a link is never signed", property_id: P-MEDIA-LINK, provenance: {kind: documented, source: 'docs/architecture.md "is never signed"'}}
downstream_must_not_assume:
- {id: dmna-signed-link-private, statement: "that a link is private: it is a bearer credential until expiry", provenance: {kind: documented, source: 'docs/architecture.md "is never signed"'}}
- component: authz
channel: "Exports, RDF, JSON-LD, IIIF manifests, WebVTT, Dublin Core"
taint: same-as-input
x-taint-note: "values as typed; RDF responses send Access-Control-Allow-Origin: *"
taint_provenance: {kind: inferred, question_id: Q24}
invariants: []
downstream_must_not_assume:
- {id: dmna-export-escaping, statement: "escaping for the consumer's format", provenance: {kind: inferred, question_id: Q24}}
- component: authz
channel: "Evidence PDFs (evidence.py, evidence_report.py)"
taint: same-as-input
x-taint-note: "the resource's file and what Lens holds about it, for anyone who can read it; places only for members of its namespace; shared notes, highlights and comments only when picked, never private notes; what a model wrote is labelled"
taint_provenance: {kind: documented, source: 'docs/api.md "never anyone''s private notes"'}
invariants: []
downstream_must_not_assume:
- {id: dmna-evidence-pdf-less, statement: "that a PDF handed on holds less than its reader could see (pick its parts)", provenance: {kind: documented, source: 'docs/api.md "never anyone''s private notes"'}}
- component: authz
channel: "Playlists (domain/playlists.py, routes/playlists.py)"
taint: same-as-input
x-taint-note: "a shared playlist's clips (titles, notes, quotes, signed media links) only where its reader has a role on the clip's resource; its name and description to everyone signed in"
taint_provenance: {kind: documented, source: 'docs/api.md "whoever made it"'}
invariants: []