tau-guard 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. tau_guard-0.4.0/.gitignore +29 -0
  2. tau_guard-0.4.0/LICENSE +21 -0
  3. tau_guard-0.4.0/PKG-INFO +330 -0
  4. tau_guard-0.4.0/README.md +296 -0
  5. tau_guard-0.4.0/pyproject.toml +51 -0
  6. tau_guard-0.4.0/src/tau_guard/__init__.py +72 -0
  7. tau_guard-0.4.0/src/tau_guard/audit.py +236 -0
  8. tau_guard-0.4.0/src/tau_guard/camera.py +358 -0
  9. tau_guard-0.4.0/src/tau_guard/cli.py +302 -0
  10. tau_guard-0.4.0/src/tau_guard/cli_face.py +984 -0
  11. tau_guard-0.4.0/src/tau_guard/cli_window.py +125 -0
  12. tau_guard-0.4.0/src/tau_guard/config.py +212 -0
  13. tau_guard-0.4.0/src/tau_guard/engine.py +544 -0
  14. tau_guard-0.4.0/src/tau_guard/enroll.py +274 -0
  15. tau_guard-0.4.0/src/tau_guard/failures.py +77 -0
  16. tau_guard-0.4.0/src/tau_guard/liveness.py +442 -0
  17. tau_guard-0.4.0/src/tau_guard/local.py +231 -0
  18. tau_guard-0.4.0/src/tau_guard/localauth.py +307 -0
  19. tau_guard-0.4.0/src/tau_guard/models/LICENSE-yunet +21 -0
  20. tau_guard-0.4.0/src/tau_guard/models/face_detection_yunet_2023mar.onnx +0 -0
  21. tau_guard-0.4.0/src/tau_guard/overlay.py +1600 -0
  22. tau_guard-0.4.0/src/tau_guard/presence.py +90 -0
  23. tau_guard-0.4.0/src/tau_guard/routes.py +166 -0
  24. tau_guard-0.4.0/src/tau_guard/runner.py +462 -0
  25. tau_guard-0.4.0/src/tau_guard/service.py +1099 -0
  26. tau_guard-0.4.0/src/tau_guard/state.py +1155 -0
  27. tau_guard-0.4.0/src/tau_guard/store.py +501 -0
  28. tau_guard-0.4.0/src/tau_guard/strings.py +736 -0
  29. tau_guard-0.4.0/src/tau_guard/testing.py +715 -0
  30. tau_guard-0.4.0/src/tau_guard/verify.py +614 -0
  31. tau_guard-0.4.0/src/tau_guard/window.py +821 -0
  32. tau_guard-0.4.0/src/tau_guard/worker.py +1190 -0
  33. tau_guard-0.4.0/tests/conftest.py +39 -0
  34. tau_guard-0.4.0/tests/guard_helpers.py +181 -0
  35. tau_guard-0.4.0/tests/hub_process.py +46 -0
  36. tau_guard-0.4.0/tests/test_audit.py +162 -0
  37. tau_guard-0.4.0/tests/test_camera.py +332 -0
  38. tau_guard-0.4.0/tests/test_cli.py +223 -0
  39. tau_guard-0.4.0/tests/test_cli_face.py +758 -0
  40. tau_guard-0.4.0/tests/test_cli_window.py +184 -0
  41. tau_guard-0.4.0/tests/test_end_to_end.py +197 -0
  42. tau_guard-0.4.0/tests/test_engine.py +278 -0
  43. tau_guard-0.4.0/tests/test_enroll.py +192 -0
  44. tau_guard-0.4.0/tests/test_integration.py +655 -0
  45. tau_guard-0.4.0/tests/test_liveness.py +572 -0
  46. tau_guard-0.4.0/tests/test_localauth.py +354 -0
  47. tau_guard-0.4.0/tests/test_models.py +265 -0
  48. tau_guard-0.4.0/tests/test_package.py +166 -0
  49. tau_guard-0.4.0/tests/test_presence_config.py +179 -0
  50. tau_guard-0.4.0/tests/test_restart.py +57 -0
  51. tau_guard-0.4.0/tests/test_service.py +633 -0
  52. tau_guard-0.4.0/tests/test_state.py +892 -0
  53. tau_guard-0.4.0/tests/test_store.py +385 -0
  54. tau_guard-0.4.0/tests/test_touch_id_hub.py +766 -0
  55. tau_guard-0.4.0/tests/test_touch_id_worker.py +637 -0
  56. tau_guard-0.4.0/tests/test_verify.py +513 -0
  57. tau_guard-0.4.0/tests/test_window.py +851 -0
  58. tau_guard-0.4.0/tests/test_worker.py +440 -0
  59. tau_guard-0.4.0/tests/test_worker_ops.py +681 -0
@@ -0,0 +1,29 @@
1
+ .DS_Store
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ *.local.env
6
+ __pycache__/
7
+ *.py[cod]
8
+
9
+ # Python / uv
10
+ .venv/
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ dist/
14
+ build/
15
+ *.egg-info/
16
+
17
+ # Personal facts about the user stay local (persona/user.example.md is the tracked template)
18
+ persona/user.md
19
+ # The public note for other taus (ADR 0007); persona/net.example.md is the tracked template
20
+ persona/net.md
21
+ # Sub-tau definitions are the owner's files (ADR 0012); packages/tau-sub/examples/ has an example
22
+ persona/subs/
23
+
24
+ # Tools tau wrote itself, waiting for approval
25
+ tools/_pending/*
26
+ !tools/_pending/.gitkeep
27
+
28
+ # MkDocs build output
29
+ site/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fport
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,330 @@
1
+ Metadata-Version: 2.5
2
+ Name: tau-guard
3
+ Version: 0.4.0
4
+ Summary: The owner guard for tau: the hub's guard service with reflex locks, a worker subprocess per check, protected /guard routes, an audit log and the tau guard command.
5
+ Project-URL: Homepage, https://github.com/fport/tau
6
+ Project-URL: Documentation, https://docs.tau.getporti.com
7
+ Project-URL: Issues, https://github.com/fport/tau/issues
8
+ Project-URL: Changelog, https://github.com/fport/tau/releases
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,assistant,face,guard,macos,presence,tau
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Home Automation
21
+ Classifier: Topic :: Security
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: cryptography>=44
25
+ Requires-Dist: keyring>=25; sys_platform == 'darwin'
26
+ Requires-Dist: numpy>=2
27
+ Requires-Dist: opencv-python-headless<5,>=4.14
28
+ Requires-Dist: pyobjc-framework-avfoundation>=12; sys_platform == 'darwin'
29
+ Requires-Dist: pyobjc-framework-cocoa>=12; sys_platform == 'darwin'
30
+ Requires-Dist: pyobjc-framework-localauthentication>=12; sys_platform == 'darwin'
31
+ Requires-Dist: pyobjc-framework-quartz>=12; sys_platform == 'darwin'
32
+ Requires-Dist: tau-core>=0.4.0
33
+ Description-Content-Type: text/markdown
34
+
35
+ # tau-guard
36
+
37
+ The owner guard for tau: tau's guarded surfaces (`tau tui`, `tau chat`, tau Desktop) work only
38
+ while the owner is verified at the Mac. The decision and its threat model are in
39
+ [ADR 0008](../../docs/adr/0008-owner-guard.md).
40
+
41
+ tau-guard is its own distribution (`tau-guard`, import `tau_guard`). It uses only the public API
42
+ of `tau-core`, whose `tau_core.guard` owns the `Guard` protocol, the reason lists and the
43
+ `/guard/*` route contract, and plugs in through two entry points:
44
+
45
+ | group | name | what |
46
+ |---|---|---|
47
+ | `tau.hub_services` | `guard` | the guard service inside `tau hub run` |
48
+ | `tau.commands` | `guard` | `tau guard setup`, `enroll`, `verify`, `forget`, `doctor`, `status`, `lock`, `log` and `window` |
49
+
50
+ Channels never import this package: they talk to the hub through tau-core's `HubGuard`, so no
51
+ camera library ever loads in a channel process.
52
+
53
+ ## What it does
54
+
55
+ - **Inert until switched on.** Without the marker `TAU_DATA_DIR/guard/enrolled` and without
56
+ `[component.guard] enabled = true`, the service only reports `guard: {state: "off"}` in
57
+ `/status`. It starts no thread, adds no route and imports neither OpenCV nor pyobjc.
58
+ - **Locked at every start.** An unlock lives in memory only; a hub start, even after
59
+ `kill -9`, is `locked` with trigger `startup`.
60
+ - **One check at a time, in a worker.** Each unlock, presence recheck or step-up runs in its own
61
+ short-lived subprocess (`python -P -m tau_guard.worker`, run in `TAU_DATA_DIR/guard/`).
62
+ The hub kills it on cancel, lock, timeout or hub stop (SIGTERM, then SIGKILL after 1 s). Its stderr goes to
63
+ `TAU_DATA_DIR/guard/worker.log`, never to a terminal. Concurrent unlocks share one attempt;
64
+ a step-up waits behind a running check.
65
+ - **Reflex locks without an LLM or the cloud.** The service locks on the longest unlock time,
66
+ on HID idle, when the screen locks and after the Mac slept. It checks these at every admission
67
+ and every verification, again when a worker's verdict arrives and when a client reads it,
68
+ and a reflex loop checks them every 2 s. A lock that comes while a check runs cancels it, so
69
+ its late match counts for nothing. If the loop stops ticking, admission refuses. No check
70
+ starts while the screen is locked.
71
+ - **Presence rechecks.** After a few minutes of idle since the last check (a face match, or
72
+ Touch ID in a session unlocked with it), or after `presence_recheck_minutes`, admission
73
+ answers `recheck`, and the channel runs a quick passive check (Touch ID in a Touch ID
74
+ session). A non-matching face locks, and so does a failed step-up or re-unlock: any check that
75
+ sees someone else while tau is unlocked.
76
+ - **Cooldown.** Three failed matches (`no_match`, `spoof`, `multiple_faces`) within 10 minutes
77
+ start a 30 s cooldown. The cooldown doubles with each further failure, up to 15 minutes.
78
+ The count and the cooldown survive a hub restart (`TAU_DATA_DIR/guard/failures.json`, 0600).
79
+ - **An audit log.** `TAU_DATA_DIR/guard/audit.jsonl` (0600, in a 0700 directory) records every
80
+ check, lock, unlock and cooldown. It never holds an embedding, a frame or anything about
81
+ another person. It is rotated at 1 MiB, with 3 files kept. The hub log gets no verdicts and
82
+ no scores.
83
+
84
+ ## Touch ID
85
+
86
+ Touch ID is the guard's second factor at the Mac (issue 114). It runs in the worker like the
87
+ camera, through LocalAuthentication, so its sheet appears on the Mac's screen, and like the
88
+ face check it can only narrow: it never produces an approval.
89
+
90
+ - **Two policies.** *Touch ID only* (LocalAuthentication's biometrics policy): no "Use
91
+ Password…" button, a reuse duration of 0, and the enrolled fingerprint set pinned: its
92
+ `domainState` (as a sha256) is stored in the encrypted template at enrollment, and a
93
+ different set (someone who knows the Mac password added a finger) is refused with
94
+ `touch_id_failed`, before the prompt and again after it. *Touch ID or the password* (the
95
+ device-owner policy, which macOS also lets a paired Apple Watch answer) has no pin: whoever
96
+ could change the fingerprints knows the password.
97
+ - **Every prompt ends.** Each `LAContext` is used once and invalidated after 30 s, after an
98
+ answer and at once when the attempt is cancelled.
99
+ - **Unavailable.** A closed lid (the built-in sensor is off), a Mac without a sensor or with no
100
+ enrolled finger, a disconnected Touch ID keyboard, a lockout after failed tries or a policy
101
+ that cannot be evaluated give `touch_id_unavailable` for Touch ID only.
102
+ - **Enrollment and forget.** The first enrollment (decided from the Keychain item, never from
103
+ the template file) uses Touch ID or the password. Re-enrollment, `enroll --add` and `forget`
104
+ use Touch ID only with the pin when the Mac has Touch ID, refuse while it cannot be used (open
105
+ the lid), and use the password only on a Mac without Touch ID. An enrollment pins the
106
+ fingerprints whenever Touch ID is available, and a pinned enrollment is only ever authorised
107
+ by Touch ID with its pin: removing every finger refuses (`touch_id_failed`) instead of
108
+ bringing the password back. A template that cannot be read refuses Touch ID only too, since
109
+ the pin cannot be compared (a Mac with no Touch ID sensor keeps the password).
110
+ - **Unlock fallback** (`unlock_fallback`, default `touch_id`). After two failed face unlocks
111
+ within 10 minutes, or a verdict that says the face check cannot work (`camera_*`, or
112
+ `unavailable` / `model_changed` for a missing or changed face model), the locked status
113
+ carries `fallback`, and a channel may ask for it (`POST /guard/verify {..., fallback: "touch_id"}`,
114
+ `Guard.unlock(method="touch_id")`). `touch_id` is Touch ID only; `touch_id_or_password` takes
115
+ the password (or an approval from a paired Apple Watch, which macOS's device-owner policy
116
+ also accepts) too, the opt-in for a closed lid. The stricter of the hub's and the client's
117
+ `unlock_fallback` applies: a client that says `touch_id` gets Touch ID only from a hub that
118
+ says `touch_id_or_password`, and one that says `none` gets no offer at all (`HubGuard`
119
+ narrows the status the same way). The hub does not probe Touch ID for the offer: on a Mac
120
+ without Touch ID the default still offers it, the attempt answers `touch_id_unavailable`,
121
+ and the doctor warns about the setting. A pass unlocks with `method = "touch_id"`
122
+ (status and audit), ends a face cooldown and clears the offer; a Touch ID failure never
123
+ counts toward the face cooldown. Touch ID has its own limit: after five prompts that did
124
+ not pass (a wrong finger, a cancel, a timeout) within 10 minutes, a new Touch ID unlock or
125
+ recheck is refused with `cooldown` until the oldest is 10 minutes old. A Touch ID unlock is
126
+ presence but not a face: rechecks follow it (and are Touch ID checks in a Touch ID session,
127
+ so a covered camera does not fail every later turn), while `face_fresh` (for voice) still
128
+ needs a face.
129
+ - **Step-up** (`step_up`, default `face`). `face`: the head-turn challenge (or a passive match
130
+ in the freshness window). `face_or_touch_id`: Touch ID only when the face check could not
131
+ work (`too_dark`, `no_face`, `timeout`, `face_too_small`, `permission_pending`, a
132
+ `camera_*` verdict, or a missing or changed face model: the pin is read with the model the
133
+ enrollment was made with); a counted failure never falls back. `touch_id`: Touch ID only, the
134
+ camera never opens (the accessible choice for an owner who cannot turn their head). `none`:
135
+ no fresh check, only when both the hub's and the client's `tau.toml` say `none`; a client
136
+ whose `tau.toml` leaves `step_up` out uses `face` (the doctor warns about `none`). A step-up
137
+ or a recheck whose Touch ID prompt refused the finger or found another fingerprint set
138
+ locks tau, like a face that does not match; a cancelled prompt does not.
139
+ - **Audit.** Each prompt writes a `touch_id` record (`policy`, the outcome as `reason`, the
140
+ attempt's kind as `trigger`), and checks and unlocks carry `method`.
141
+
142
+ If the fingerprints changed since the enrollment (or the enrollment cannot be read),
143
+ `tau guard doctor` says so and how to start over: remove the template key (`security delete-generic-password -s tau-guard -a
144
+ template-key-v1`), then run `tau guard setup` at the Mac.
145
+
146
+ ## The face check
147
+
148
+ Everything runs in the worker, on this Mac; frames exist only in the worker's memory and are
149
+ never saved.
150
+
151
+ - **Camera.** Only the built-in camera: the AVFoundation device of type
152
+ `AVCaptureDeviceTypeBuiltInWideAngleCamera` on transport `bltn`. Continuity Camera and
153
+ virtual cameras (OBS) are never picked, because keeping them out is the only defence against
154
+ injected frames. The OpenCV index is the device's place in the uniqueID-sorted list of video
155
+ and muxed devices, the order OpenCV's AVFoundation backend uses; the list is read again right
156
+ after the open, and a change aborts with `camera_unavailable`. The worker checks the camera
157
+ permission itself (`permission_pending` asks macOS, `camera_denied`), drops 5 warm-up frames,
158
+ keeps the camera on for at most 20 s and releases it on every path, a kill included.
159
+ - **Models.** YuNet 2023mar finds faces and five landmarks. SFace 2021dec turns a face into an
160
+ L2-normalised 128-d embedding, compared with the template by cosine similarity. Both files
161
+ are sha256-checked at every load. Only `opencv-python-headless` and `numpy` are needed.
162
+ - **Quality gates.** A frame counts when the detector score is at least 0.90, the eye distance
163
+ at least 40 px (hint `face_too_small`), the brightness at least 60 (hint `too_dark`) and the
164
+ sharpness at least 30. A second face counts only when its eye distance is at least 45 % of
165
+ the owner's and at least 40 px, so a photo on the wall does not block; a counted one aborts
166
+ with `multiple_faces`.
167
+ - **Match.** At least 3 of the last 5 quality frames reach the threshold: `unlock_threshold`
168
+ 0.50 (never below 0.45), `step_up_threshold` 0.55 for step-ups and presence rechecks.
169
+ - **Head-turn challenge.** After a frontal match, 2 random steps for an unlock and 3 for a
170
+ step-up. The head's yaw comes from `cv2.solvePnP`, which fits YuNet's five landmarks to a
171
+ generic 3D head (degrees, relative to the frontal frames of the attempt): turn left or right
172
+ by at least `max(12°, half your calibrated turn)`, hold for a random 0.3-0.6 s, come back
173
+ within 6° (a swing past the centre on the way back is fine). Each prompt follows a random
174
+ 0.3-0.9 s pause and has 4 s. The check fails as `spoof`, naming the rule in the verdict and
175
+ the audit log: 8° the wrong way before the turn is held (`overshoot_before_target`), more
176
+ than 3 reversals within one step (`oscillation`), more than 10° of movement while no prompt
177
+ is up (`moved_between_prompts`; up to 20° on the far side of a return swing), and a step not
178
+ done in time (`no_turn`, `too_slow`, `no_return`). A flat photo turned about the vertical
179
+ axis reads a few degrees at most, and turned further its landmarks stop fitting a head, so
180
+ those frames count for nothing. Exactly one face stays in view (box IoU above 0.3 from frame
181
+ to frame, never gone for more than 0.2 s: `face_lost`), the frames near the centre between
182
+ the turns must match you too (`step_identity`), and a second frontal match closes the check.
183
+ Cancelling or locking while a turn prompt is up counts like a failed step (`abandoned`). A
184
+ prompt is shown only when its step can end before the camera's 20 s limit. A presence
185
+ recheck is a passive strict match, and so is a step-up within
186
+ `step_up_challenge_fresh_seconds` of a passed challenge.
187
+ - **Enrollment.** Guided, 15-20 s at most: look, turn left, turn right, up, down, with live
188
+ hints. 10-20 samples, none closer than 0.97 to another, each at least 0.55 to the mean of the
189
+ others (leave-one-out); the template is their renormalised mean, stored with the turn
190
+ calibration in degrees (an enrollment from before degrees keeps working with a 12° turn each
191
+ way; `tau guard enroll` measures your own range). It always runs behind LocalAuthentication (Touch ID or the Mac password, asked
192
+ only once the camera permission is granted), is refused over SSH, and runs through the hub
193
+ when a hub with an active guard runs (`POST /guard/enroll`), else in a local worker. On a
194
+ first `tau guard setup` the hub's guard is not active yet, so the app that runs the hub asks
195
+ for its own camera permission at the first unlock.
196
+ - **Storage.** `TAU_DATA_DIR/guard/owner.face.v1.bin` (0600, atomic) is
197
+ `b"TAUG1" | nonce | AES-GCM(payload)` with the model's sha256 as associated data; the 256-bit
198
+ key is in the macOS Keychain (service `tau-guard`, account `template-key-v1`; always keyring's
199
+ macOS backend, whatever `PYTHON_KEYRING_BACKEND` says) and is read only in the worker, with a
200
+ timeout. A copy of the data dir or a backup cannot decrypt it. The public marker
201
+ `guard/enrolled` holds `{v, enrollment, model, created}` and nothing biometric; a template
202
+ file must carry the marker's enrollment id. The template is read again for every check, so
203
+ `tau guard forget` works at once.
204
+
205
+ **Honest limits.** Without an infrared camera, a 2D webcam check is a presence and convenience
206
+ factor. It keeps a housemate out of tau's own surfaces and stops low-effort photo and screen
207
+ attacks. It does not stop a real-time deepfake, a determined attacker with a good video, or
208
+ anyone with a shell in your account: a synthetic looping clip of natural head turns passes a
209
+ few percent of the unlock challenges (see issue 112). That is why it is only ever required in
210
+ addition to the human approval.
211
+
212
+ ### Models and credits
213
+
214
+ | Model | License | How it arrives |
215
+ |---|---|---|
216
+ | YuNet 2023mar (`face_detection_yunet_2023mar.onnx`, sha256 `8f2383e4...52fa4`) | MIT, © 2020 Shiqi Yu (`tau_guard/models/LICENSE-yunet`) | in the wheel |
217
+ | SFace 2021dec (`face_recognition_sface_2021dec.onnx`, sha256 `0ba9fbfa...34e79`) | Apache-2.0 (OpenCV Zoo) | `tau guard setup` downloads it from opencv_zoo at commit `47534e2`, or `--model-file PATH` |
218
+
219
+ ## The guard window
220
+
221
+ While a check runs, the worker that holds the camera covers the main screen (below the menu
222
+ bar) with a window drawn in the tau brand: the mirrored picture inside the neon τ ring, the
223
+ step as a large triangle and an uppercase line (`TURN LEFT`), the countdown as a comet on the
224
+ ring, the live hint, the purpose, then the result for 1.2 s (`tau_guard/overlay.py`, pyobjc
225
+ AppKit imported lazily). Frames never leave the worker; the verifier gets every frame and the
226
+ window at most 20 a second (`tau_guard.window.WatchedSource`). During a Touch ID prompt the
227
+ window is a small panel at the top, so the macOS prompt stays uncovered. Esc (or a click on
228
+ the chip) writes `{"type": "cancel"}`, and the hub cancels the attempt through its usual path.
229
+
230
+ The hub and the CLI ask for it with a `window` object in the request (the lines already in
231
+ the owner's UI language: the worker never imports tau-core), from `[component.guard] window`
232
+ and, for the CLI's local checks, `--no-window`. Not on macOS, over SSH, without a GUI session,
233
+ without pyobjc's AppKit or with `TAU_GUARD_WINDOW=0` the worker runs headless and logs why.
234
+ `tau guard window --demo` plays a scripted check in it without the camera. The seam is
235
+ `tau_guard.window.GuardView`; `tau_guard.testing.RecordingView` records its calls in tests.
236
+
237
+ ## Routes
238
+
239
+ All routes are protected control routes. `ControlApi` checks each request's signature and
240
+ signs each answer. No route accepts a verdict, an image or an embedding: a route can only ask
241
+ the hub to look.
242
+
243
+ | Route | Body or query | Answer |
244
+ |---|---|---|
245
+ | `GET /guard/status` | `?since=<seq>&wait=<0..25>[&attempt=<id>]` | every `GuardStatus` field (`fallback` too) plus `enrollment`, `screen`, `step_up_active` and `attempt` |
246
+ | `POST /guard/admit` | `{channel, session_id, client_id}` | `{allowed, reason, enrollment, state}` |
247
+ | `POST /guard/verify` | `{channel, client_id, kind, request_id?, tool?, policy?, fallback?}` | `202 {attempt, seq}`, or `409`/`429 {reason, retry_after?}` |
248
+ | `POST /guard/cancel` | `{client_id, attempt?}` | `{ok}` |
249
+ | `POST /guard/lock` | `{channel, client_id, trigger}` | `{state: "locked"}` |
250
+ | `POST /guard/enroll` | `{client_id, add?}` | `202 {attempt, seq}`; LocalAuthentication first |
251
+
252
+ A body with an unknown key or a wrong type gets `400 {reason: "error"}`. The open `/status`
253
+ gains only `guard: {state, enrolled, available, reason}`.
254
+
255
+ Inside the hub, `GuardService` is a `tau_core.Guard`: find it with `hub.find_service("guard")`
256
+ plus an `isinstance(service, Guard)` check. It also offers `subscribe(callback)` for state
257
+ changes and `face_fresh(seconds)`.
258
+
259
+ ## Configuration
260
+
261
+ Every key is optional. A value out of range or of the wrong type makes the guard `unavailable`,
262
+ with the reason in the log. The guard never falls back to a weaker value.
263
+
264
+ ```toml
265
+ [component.guard]
266
+ enabled = true # or the enrollment marker
267
+ max_unlock_hours = 8 # 1-24, on the wall clock
268
+ idle_lock_minutes = 10 # 1-240; also the cap while the screen state is unknown
269
+ recheck_after_idle_minutes = 3 # 1-240
270
+ presence_recheck_minutes = 30 # 1-1440
271
+ verify_timeout_seconds = 15 # 5-60 for the frontal match; the camera stops at 20 s anyway
272
+ unlock_threshold = 0.50 # 0.45-0.95
273
+ step_up_threshold = 0.55 # 0.45-0.95; step-ups and presence rechecks
274
+ step_up_challenge_fresh_seconds = 120 # 0-600; a step-up this soon after a challenge is passive
275
+ unlock_liveness = "challenge" # or "none" (the doctor warns: a photo could unlock)
276
+ step_up = "face" # face | face_or_touch_id | touch_id | none (both configs)
277
+ unlock_fallback = "touch_id" # touch_id | touch_id_or_password | none (both configs)
278
+ camera = "builtin" # or a camera's AVFoundation uniqueID
279
+ window = true # the guard window during a check (display only)
280
+ ```
281
+
282
+ A client sends its own `step_up`, `unlock_liveness` and `unlock_fallback` values with every
283
+ check (the defaults, `face`, `challenge` and `touch_id`, for a key its `tau.toml` leaves out),
284
+ and the hub applies the stricter of its own value and the client's; it reads a key missing from
285
+ a client's request as that default, never as agreement. The hub's own channels follow the hub's
286
+ `tau.toml` alone.
287
+
288
+ ## Commands
289
+
290
+ ```sh
291
+ tau guard setup [--model-file PATH] # model, enrollment, one check, [component.guard] enabled = true
292
+ tau guard enroll [--add] # enroll again, or add samples under other light
293
+ tau guard verify [--explain[=all]] # one check; --explain highlights each prompt and prints
294
+ # every 3rd frame's numbers (=all: every frame)
295
+ # (setup, enroll, verify: --no-window for their own checks)
296
+ tau guard window [--demo] # can the guard window open here; --demo plays a scripted check
297
+ tau guard forget # remove template, marker and key; the guard is off
298
+ # (exit 1 if the Keychain kept the key; run it again)
299
+ tau guard doctor # everything the guard depends on, Touch ID included
300
+ tau guard status [--json] # the state as the running hub reports it
301
+ tau guard lock # lock every guarded channel now
302
+ tau guard log [--since 2h] [--json] # the audit log
303
+ ```
304
+
305
+ `setup`, `enroll` and `forget` run only at the Mac (macOS, no `SSH_CONNECTION`). After
306
+ `setup`, restart the hub and any open `tau chat`, `tau tui` or tau Desktop window. While you
307
+ are away from the Mac, remote `tau tui` and `tau chat` and `tau ask` stay locked (until issue
308
+ 117).
309
+
310
+ ## Tests
311
+
312
+ `tau_guard.testing` has `FakeClock`, `FakePresence`, a fake worker
313
+ (`fake_worker_command(script)`) that speaks the worker protocol from a JSON script, and fakes
314
+ for the face check (and `RecordingView`, a guard window that records its calls): synthetic YuNet rows and embeddings (`face_row`, `identity`, `variant`), a
315
+ `FakeEngine`, a `FakeFrameSource`, a `SimulatedUser` who follows the prompts (with switches for
316
+ every way an attempt must fail), `FakeLocalAuth` (Touch ID's state and scripted answers,
317
+ `FINGERPRINTS` and `OTHER_FINGERPRINTS`) and `fake_deps` for running the worker's operations
318
+ in-process. `MacLocalAuth` is tested against a fake `LocalAuthentication` module. No test opens a camera, a window, a microphone, the Keychain or a Touch ID prompt,
319
+ and every macOS call sits behind a protocol and is imported lazily, so the suite runs on Linux.
320
+
321
+ The real-model tests are opt-in: they fetch SFace and a few public-domain portraits by pinned
322
+ URL and sha256 into a cache outside the repo (`TAU_GUARD_TEST_CACHE`, default
323
+ `~/.cache/tau-guard-tests`; `TAU_GUARD_SFACE=PATH` installs a local SFace file instead). No
324
+ face image is ever committed.
325
+
326
+ ```sh
327
+ uv run pytest -q packages/tau-guard
328
+ uv run pytest -m models packages/tau-guard # opt-in, real models
329
+ uv build --package tau-guard
330
+ ```