handnewb/lock-on-absence
🔒 Auto-lock your screen when you walk away. Face recognition via LBPH, SFace, or LLM (OpenAI, Anthropic, Ollama, Hermes Agent). Privacy-first: local-first, egress gate, TrustPolicy tiers T3→T0.
Lock on Absence is a Python-based tool that automatically locks a workstation when the user is no longer present. It uses webcam frames and various recognition backends to determine presence and manage workstation state.
- Supports LBPH, SFace, and LLM recognition backends.
- Includes a watchdog to lock the screen if the agent fails.
- Provides a FAR/FRR measurement harness and SIEM audit trail.
full readme from github
Lock on Absence
Auto-lock your workstation when you walk away. Facial recognition, a pure-Python decision engine, a FAR/FRR measurement harness, and a SIEM audit trail — no special hardware required.
Table of Contents
- How it works
- Installation
- Quick start
- CLI reference
- FAR/FRR harness
- SIEM / event log
- Decision engine
- Architecture
- Security model
- Limitations
- Roadmap
- Contributing
- License
How it works
┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────────┐
│ Webcam │──▶│ Detector │──▶│ Recognizer │──▶│ PSM │──▶ LOCK / KEEP / PAUSE
│ (Haar / │ │ (Haar / │ │ (LBPH / │ │ (pure Python │ │
│ YuNet) │ │ YuNet) │ │ SFace*) │ │ state │ ▼
└──────────┘ └──────────────┘ └────────────┘ │ machine) │ ┌──────────────┐
└──────────────┘ │ SIEM Output │──▶ lock-events.json
│ │ Event Log │──▶ Event ID 1001-2001
▼ └──────────────┘
┌──────────────┐
│ Watchdog │──▶ fails closed if the agent dies
└──────────────┘
The main loop captures frames from any webcam, detects faces (Haar cascades by default, YuNet DNN optional), and verifies identity via one of four backends:
| Backend | How it works | Network |
|---|---|---|
| LBPH | OpenCV local face recognition (chi-square distance) | None |
| SFace | ONNX embeddings with cosine similarity | None |
| LLM | Vision LLM (OpenAI GPT-4o, Claude, Ollama) compares frame to owner | API call |
| Hermes | Local Hermes Agent uses its vision model to identify the owner | Local |
Each frame is reduced to an Observation and fed into PresenceStateMachine (lock_on_absence/state_machine.py) — a pure-Python module with zero dependencies — which returns a Verdict:
- KEEP — presence proven, screen stays unlocked
- WARN — transition state, no action yet
- LOCK — lock the workstation for a specific
Reason(intruder, absence, body-timeout, camera failure, spoof) - PAUSE — camera is busy (video call); stop deciding, and resume the instant the camera is usable again
The agent loop is a dumb adapter: build Observation → step() → execute Verdict. Every decision, timer and threshold lives in the state machine, so the executed code is the tested code. An external watchdog (lock-on-absence-watchdog) locks the screen if the agent stops proving it is alive (stale heartbeat, dead PID, or clock tampering).
LLM & Hermes Recognition (v5.4+)
A vision LLM or Hermes Agent provides a second opinion alongside the local biometric (SFace/LBPH). The model proposes; deterministic policy authorizes. At the default T1 tier, the LLM can withdraw presence but never grant it — this catches printed photos, shoulder-surfing, and other attacks a template match alone cannot.
# T1 (default) — LLM can deny, never grant. Biometric is required.
lock-on-absence --recognizer llm --llm-provider ollama
# T0 autonomous — LLM may grant presence without biometric.
# Requires --llm-autonomous; refused for cloud providers.
lock-on-absence --recognizer llm --llm-tier T0 --llm-autonomous
# Hermes Agent (local, authenticated, audit sink)
lock-on-absence --recognizer hermes --hermes-token-file ~/.secrets/hermes.token \
--owner-name "Alice"
# T3 shadow — Hermes observes, logs, decides nothing. Measure first.
lock-on-absence --recognizer hermes --llm-tier T3 --debug --run-seconds 300
| Tier | LLM may | Grants presence | When to use |
|---|---|---|---|
| T3 shadow | log only | no | First 1–2 weeks. Measure disagreement against biometric. |
| T2 advise | warn + event | no | Want alerting, not enforcement |
| T1 guarded | withdraw presence | no | Default. Biometric grants; either party can deny. |
| T0 autonomous | withdraw and grant | yes | No enrollment possible. Needs --llm-autonomous; refused for cloud. |
Security: The grammar is strict (one token per line, NFKC-normalised, no
substring fallback). An injected OWNER cannot grant below T0. Egress to cloud
providers (openai, anthropic) requires --llm-accept-egress — facial
images of a named person are sensitive personal data under LGPD Art. 5º II.
Installation
Requires Python 3.10+.
Windows
# 1. Install Python 3.10+ (https://python.org)
# 2. Open PowerShell (no admin needed)
cd C:\Users\handn\LockCam
.\install.ps1 # venv + Scheduled Tasks (agent + watchdog), opt-in
.\lock-on-absence-enroll # capture your face
.\lock-on-absence
install.ps1 registers two visible Scheduled Tasks (LockOnAbsence, LockOnAbsenceWatchdog) that start at logon. It deliberately does not drop a hidden script into the Startup folder — autostart you can see and stop.
Linux
git clone https://github.com/handnewb/lock-on-absence.git
cd lock-on-absence
bash install.sh # venv + systemd user units (agent + watchdog), opt-in
lock-on-absence-enroll # capture your face
lock-on-absence
macOS
git clone https://github.com/handnewb/lock-on-absence.git
cd lock-on-absence
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
lock-on-absence-enroll # capture your face
lock-on-absence
Quick start
# 1. Enroll your face (creates face_model.yml + face_model.json, chmod 600)
lock-on-absence-enroll
# 2. Run — fail-closed security mode (default)
lock-on-absence
# 3. Dry-run — decide and log, never lock (FAR/FRR measurement)
lock-on-absence --no-lock --siem lock-events.json
# 4. Measure before you trust a threshold
lock-on-absence-replay --synthetic --repeat 8 --far-window 12
All four commands also run from the repo root as python lock-on-absence.py, python enroll.py, python watchdog.py, python replay.py, or via python -m lock_on_absence.
CLI reference
lock-on-absence (agent)
| Flag | Default | Description |
|---|---|---|
--mode {security,convenience} |
security |
security = fail-closed defaults (lock on camera failure, body-only ceiling); convenience = tolerant (warn instead of lock where safe) |
--delay |
10 |
Seconds with nobody present before locking |
--check-interval |
1.5 |
Seconds between camera reads |
--cooldown |
30 |
Quiet period after a lock (keep-awake never active during it) |
--max-body-only |
20 |
Max seconds with body-only detection before timeout lock |
--max-without-face |
90 |
Absolute ceiling since the last recognized face (immune to body-window refresh) |
--intruder-count |
2 |
Non-owner detections (sliding window) needed to lock |
--intruder-window |
6 |
Seconds for the sliding intruder window |
--startup-grace |
5 |
Suppress intruder lock right after start (never suppresses absence/camera) |
--on-camera-failure {lock,warn} |
lock |
Fail-closed vs warn when the camera dies (mode overrides in security) |
--camera-fail-grace |
20 |
Seconds of camera failure before fail-closed lock |
--meeting-pause |
30 |
Maximum pause when another app holds the camera. Ends early the moment the camera is readable again, so a one-second grab does not buy 30s of blindness. Triggers after 5 busy checks; 15 min cumulative budget of real elapsed time. |
--anti-spoof-timeout |
0 |
Seconds of perfectly static face before a spoof lock (0 = disabled; weak heuristic, not liveness) |
--any-face |
— | Accept ANY face as owner. INSECURE: disables recognition-based intrusion detection |
--camera |
0 |
Camera index |
--model |
./face_model.yml |
LBPH model path |
--yunet |
— | YuNet DNN detector instead of Haar cascades |
--recognizer |
sface |
lbph / sface / llm / hermes — recognition backend |
--stealth |
— | Open/close camera per frame. NOT RECOMMENDED |
--no-keep-awake |
— | Never suppress the OS idle timeout |
--no-lock |
— | Dry run: decide and log, never lock (events tagged dry_run:true) |
--event-log |
— | Write to Windows Event Log / syslog |
--siem |
— | Append JSON-lines events to this file |
--log-file |
— | Append log to this file |
--debug |
— | Log detection detail every ~28s |
--status |
— | Print config (tier, provider, budget) and exit; with --json for machine parsing |
--run-seconds |
0 |
Exit cleanly after N seconds (smoke test, CI). 0 = run until stopped |
LLM / Hermes flags (with --recognizer llm or --recognizer hermes)
| Flag | Default | Description |
|---|---|---|
--llm-provider |
ollama |
ollama / hermes / openai / anthropic |
--llm-model |
provider default | Model name override |
--llm-api-key-file |
— | File containing the API key, chmod 400. No --llm-api-key: credentials in argv are readable by any local user via /proc |
--llm-base-url |
provider default | Custom API base URL |
--llm-tier |
T1 |
T3 shadow / T2 advise / T1 guarded / T0 autonomous |
--llm-autonomous |
— | Required for T0. Refused for cloud providers |
--llm-accept-egress |
— | Acknowledge biometric egress to cloud (LGPD Art. 5º II / Art. 33). Recorded in event log |
--llm-interval |
2.0 |
Minimum seconds between inference calls (decision loop never waits) |
--llm-quorum |
2 |
Agreeing samples before LLM may grant (T0). Doubt is cheaper: deny_quorum=1 |
--llm-deny-quorum |
1 |
Samples to withdraw presence |
--llm-stale-after |
12.0 |
Verdict decays to UNKNOWN (fail-closed) when older than this |
--llm-max-per-hour |
600 |
Hard ceiling per rolling hour + circuit breaker after 3 consecutive failures |
--llm-deny-on-unknown |
— | Treat UNKNOWN as grounds to withdraw. Off by default: the state machine already owns absence |
--hermes-url |
http://127.0.0.1:11435 |
Hermes Agent HTTP API URL |
--hermes-token-file |
— | Bearer token file, chmod 400. Falls back to $HERMES_TOKEN |
--hermes-no-events |
— | Disable audit event emission to Hermes |
--owner-name |
— | Owner name interpolated into the vision prompt |
--owner-profile |
./owner_profile.json |
OwnerProfile JSON (name + description only; no reference photo) |
lock-on-absence-enroll
| Flag | Default | Description |
|---|---|---|
--samples |
30 |
Face samples per user |
--camera |
0 |
Camera index |
--output |
face_model.yml |
Output model path (metadata goes next to it) |
--users |
— | Comma-separated user names (e.g. Alice,Bob) |
--purge |
— | Delete model + metadata, revoke all enrolled users |
--no-consent |
— | Skip the consent prompt (automated enrollment) |
lock-on-absence-watchdog
| Flag | Default | Description |
|---|---|---|
--heartbeat |
watchdog_heartbeat.txt |
Heartbeat file the agent writes every iteration |
--pid-file |
— | Also require this PID to be alive (LIVENESS check) |
--max-age |
120 |
Heartbeat older than this is stale (seconds) |
--interval |
30 |
Seconds between checks |
--once |
— | Check once and exit (cron / Scheduled Task) |
--dry-run |
— | Report what would happen; never lock |
--lock-missing |
— | Treat a missing heartbeat as stale (off by default: first boot has no file) |
--print-unit / --print-task |
— | Print an installable systemd unit / PowerShell Scheduled Task |
Design: LATCH (lock once per staleness episode, not every 30s forever), SKEW-SAFE (a future timestamp is tampering, not freshness), LIVENESS (prefers asking the OS about the process over trusting a user-writable file).
lock-on-absence-replay (FAR/FRR harness)
| Flag | Description |
|---|---|
--synthetic |
Built-in 300s scenario, no files needed (CI-runnable) |
--video FILE |
Replay a real recording through the full vision pipeline |
--scenario FILE |
Replay a .jsonl of Observations (pure logic, deterministic) |
--record FILE |
With --video: write a scenario file instead of scoring |
--labels FILE |
Ground-truth CSV: start_sec,end_sec,truth (owner/absent/intruder/body_only) |
--repeat N |
Repeat the synthetic block for finer resolution |
--flicker F |
Drop this fraction of detections (simulate Haar losing faces at an angle) |
--sweep k=v,... |
Re-score across values, e.g. delay=5,10,20 |
--fail-if-far-above X |
Exit 1 if FAR > X (CI gate) |
--fail-if-frr-above X |
Exit 1 if FRR > X (CI gate) |
--json FILE |
Write the report as JSON |
FAR/FRR harness
Before v5.0, the recognition threshold was tuned by feel: 85 → 60 → 30 → 55 → 65 in a single day, none of it measured. The repo rule now: nothing gets tuned again without a before/after table.
Explicit metric definitions (each project uses these terms differently):
| Metric | Definition used here |
|---|---|
| FAR | fraction of intruder intervals in which the screen did not lock within --far-window |
| FRR | fraction of owner intervals in which the screen locked |
| TTL | seconds from the start of an absent interval to the lock (median and p90) |
| spurious/h | locks during owner per hour of owner presence |
Current numbers (default config, synthetic scenario, --repeat 8):
FAR intruder missed 0.0% (0/8 intervals)
FRR owner rejected 0.0% (0/32 intervals)
spurious locks/hour 0.00
time-to-lock median 11.0s
time-to-lock p90 11.5s
The harness also exposed the real bottleneck under bad detection: with 60% of detections dropped, raising intruder_count from 1 to 2 makes FAR jump to 25% — and varying the intruder window from 1.6s to 12s changes nothing. That is the kind of conclusion you cannot get from reading code.
These numbers are NOT product validation. The synthetic scenario measures the state machine, not the computer vision. The number that matters comes from your labeled video, in your lighting:
# 1. record ~30-60 min of your desk, then label it (owner/absent/intruder/body_only)
lock-on-absence-replay --video mesa.mp4 --record mesa.jsonl
lock-on-absence-replay --scenario mesa.jsonl --labels mesa.csv
lock-on-absence-replay --video mesa.mp4 --labels mesa.csv --model face_model.yml
The gates run in CI: any change that makes the machine miss an intruder or lock on the owner's face breaks the build. There is a test that verifies the gate can fail — a gate that cannot fail is not a gate.
SIEM / event log
With --siem <path>, each lock event is appended as newline-delimited JSON:
{"timestamp":"2026-07-30T10:15:30","event_id":1001,"event":"intruder_lock","message":"Screen locked: intruder detected (non-owner face)","hostname":"WORKSTATION-01","dry_run":false}
Event ID reference
| ID | Name | Severity | Trigger |
|---|---|---|---|
| 1001 | intruder_lock |
WARN | Non-owner face detected above the sliding-window threshold |
| 1002 | absence_lock |
INFO | No face, no body — absence delay expired |
| 1003 | spoof_lock |
WARN | Face static for --anti-spoof-timeout seconds |
| 1004 | body_timeout_lock |
WARN | Body-only detection exceeded --max-body-only (or the 90s ceiling) |
| 1005 | lock_failed |
ERROR | All lock mechanisms returned failure (emitted instead of the cause event, never alongside it) |
| 2001 | camera_error |
ERROR | Persistent camera read failure → fail-closed lock |
All events carry hostname and dry_run (true when --no-lock is active), for cross-endpoint correlation in Splunk, Elastic, or Sentinel. With --event-log, the same events go to Windows Event Log (source: LockOnAbsence) or syslog (tag: lock-on-absence).
Decision engine
lock_on_absence/state_machine.py is the single source of truth for every lock decision. It imports nothing from OpenCV, makes no OS calls, and every timer reads Observation.t — which is what makes the 28 state-machine tests run in milliseconds with no camera.
from lock_on_absence.state_machine import (
PresenceStateMachine, Config, Observation, State, Decision, Reason,
)
psm = PresenceStateMachine(Config(absence_delay=10.0))
st = State()
obs = Observation(t=time.monotonic(), faces=1, owner_recognized=True,
scene_unchanged=True, camera_ok=True)
verdict = psm.step(obs, st)
# verdict.decision == Decision.KEEP, verdict.reason == Reason.NONE
# verdict.message is populated only on a phase TRANSITION (no log spam)
# verdict.keep_awake tells the adapter whether to suppress the OS idle timeout
Decision priority
- PAUSE — camera busy; budget-capped at 15 min so it cannot hide a dead camera forever
- Cooldown — recent lock blocks all checks;
keep_awake=Falseeven on KEEP - Camera failure — after
--camera-fail-grace(20s default) → fail-closed lock - Owner recognized → KEEP (resets all timers)
- Intruder → LOCK after N non-owner detections inside a sliding window (flicker-proof: a frame with no face does NOT clear the window)
- Absence → LOCK after
--delaywithout face or body - Body-only → LOCK after
--max-body-only, hard-capped by--max-without-facesince the last recognized face (the window cannot renew forever)
Config.__post_init__ validates and enforces mode: security forces fail-closed camera behavior and clamps max_body_only; convenience downgrades lock to warn where safe. Observation.__post_init__ rejects incoherent input (e.g. owner_recognized with zero faces) — an adapter bug becomes an exception, not a wrong silent decision.
Tests (368 passing, 4 skipped)
pip install -e ".[dev]"
python -m pytest -v
| Suite | Count | Validates |
|---|---|---|
tests/test_state_machine.py |
34 | Every decision path, determinism, reason reachability, config validation, anti-spoof, pause budget, cooldown invariant |
tests/test_replay.py |
47 | Harness scoring, gates (including that they can fail), watchdog (latch/skew/liveness), smoke tests (imports, instantiation, shims, --help), architectural guard |
tests/test_recognizers.py |
29 | SFace/LBPH scoring, best_of, polarity guards |
tests/test_llm_verdict.py |
71 | Strict grammar, BYPASS_CORPUS regression (12 strings that unlocked in v5.3.0), NFKC homoglyph defence, negator coverage, injection screen |
tests/test_fusion.py |
139 | Exhaustive sweep 4 tiers × 4 verdicts × confirmed × faces; T0 cloud refusal; T3 transparency; deny_on_unknown opt-in |
tests/test_llm_integration.py |
46 | Worker non-blocking (submit < 0.5s with stuck provider), budget sliding window + circuit breaker, frame minimisation (4 leak paths closed), Hermes client auth + endpoint probe, egress gate |
The architectural guard (test_agent_contains_no_presence_logic) reads agent.py and fails if legacy decision variables — or any direct mutation of machine state (st.x =) — creep back into the adapter. It also verifies the root shims still work, so a deleted shim breaks CI.
Architecture
lock-on-absence/
├── pyproject.toml # package definition, 4 entry points, deps, tooling
├── lock_on_absence/
│ ├── __init__.py # __version__ = "5.4.0" (single source)
│ ├── __main__.py # python -m lock_on_absence
│ ├── agent.py # dumb adapter: frame → Observation → Verdict → execute
│ ├── state_machine.py # pure decision logic (no deps, 34 tests)
│ ├── recognizers.py # SFace/LBPH backends + Recognizer Protocol + polarity guard
│ ├── llm_verdict.py # strict grammar, Verdict enum, VerdictAggregator, injection screen
│ ├── llm_worker.py # InferenceWorker (non-blocking), Budget (sliding window + CB), prepare_frame (data minimisation)
│ ├── fusion.py # TrustPolicy — deterministic authorization table (T3→T0)
│ ├── llm_recognizer.py # provider callables (ollama/openai/anthropic/hermes), LLMSensor, FusedRecognizer
│ ├── hermes.py # HermesClient (auth, endpoint probe, audit sink), OwnerProfile
│ ├── face_utils.py # detectors, KeepAwake, lock_screen, SIEM, EventLogger
│ ├── enroll.py # face enrollment, --purge, --no-consent
│ ├── replay.py # FAR/FRR harness (synthetic / video / record / sweep)
│ └── watchdog.py # external watchdog (LATCH + SKEW-SAFE + LIVENESS)
├── hermes/
│ ├── skills/lock-on-absence/SKILL.md # fleet skill: tiers, contract, failure modes
│ ├── skills/lock-on-absence/manifest.json # entrypoints, capabilities, secrets
│ └── policy/lock-on-absence.yaml # deterministic policy + promotion_gate
├── lock-on-absence.py # root shims (8 lines) — keep old commands working
├── enroll.py # (installers, README, muscle memory)
├── watchdog.py
├── replay.py
├── install-hermes.ps1 # Hermes-native installer (8 steps, ACL owner-only)
├── tests/
│ ├── test_state_machine.py # 34 tests
│ ├── test_replay.py # 47 tests
│ ├── test_recognizers.py # 29 tests
│ ├── test_llm_verdict.py # 71 tests (incl. BYPASS_CORPUS)
│ ├── test_fusion.py # 139 tests (exhaustive sweep)
│ └── test_llm_integration.py # 46 tests (worker, budget, Hermes, e2e fusion)
├── install.sh # Linux: venv + systemd user units (opt-in)
├── install.ps1 / install.bat # Windows: visible Scheduled Tasks (opt-in)
├── .github/workflows/ci.yml # matrix 3 OS × 2 Pythons + FAR/FRR gates
├── MIGRATION.md # v4.1 → v5.0 migration record
└── LICENSE # MIT
Key design decisions
- The state machine is the product — every decision is a pure function of
(Observation, State), which is why 110 tests run in ~4s with no camera and the executed code is the tested code. - The agent owns no timers and no branches — any new
ifabout presence belongs instate_machine.py, notagent.py(enforced by CI). - Fail-closed by default — dead camera, crashed agent (watchdog), or unhandled exception in the loop all end in a locked screen, never an open one.
- Nothing is tuned without a measurement — the FAR/FRR harness replaced guess-based threshold tuning; CI gates block regressions.
- SIEM is the evidence trail — who was at the workstation, when they left, and whether the screen locked, in structured JSON with
dry_runtagging. - Honest security claims — no blink-level liveness; the movement anti-spoof is documented as a weak heuristic and off by default.
- All internal durations use
time.monotonic()— immune to system clock jumps. The heartbeat file usestime.time()only for watchdog compatibility, with future-timestamp tampering treated as suspicious. - Installers are visible — opt-in autostart via systemd units / Scheduled Tasks, never a hidden startup script (EDR/stalkerware signature).
What v5.1 changed
Three findings from the post-v5.0 audit, all with regression tests:
Pause no longer means blind. The adapter reads the camera every tick, so
Observation.camera_ok already told the state machine when the device came back —
and the pause branch returned before looking at it. Any process that grabbed the
webcam for one second bought a full --meeting-pause of blindness, during which
an intruder could not be locked out either. Measured before the fix: 26 seconds of
PAUSE with the camera free and the owner's face visible. After: 0. The pause
budget now also counts real elapsed time instead of the nominal window, so
meeting_pause_max means real seconds.
Mode clamps are no longer silent. --mode security overrides settings that
would weaken it — --max-body-only 60 becomes 20, --on-camera-failure warn
becomes lock. It used to do that without a word, so you believed a flag took
effect when it had not. Every override is now recorded in Config.clamps and
logged at startup as NOTE: max_body_only=60 clamped to 20 by mode=security.
The model file is integrity-checked. enroll records a SHA-256 of
face_model.yml in face_model.json, and the agent refuses to start (exit 3) if
they disagree. Be precise about what this buys: it is tamper detection, not
prevention. An attacker who can rewrite the model can usually rewrite the JSON
too. What it does buy is catching corruption, and forcing any tamper to be
consistent across two files instead of one. Real prevention needs the key in an OS
keyring (DPAPI / Keychain / Secret Service) — that is on the roadmap, not done.
File permissions are also fixed on Windows: os.chmod(path, 0o600) is close to a
no-op on NTFS, where inherited ACLs can leave the file readable by other local
accounts. restrict_file_permissions() now uses icacls /inheritance:r there,
and reports honestly when it cannot.
Security model
| Property | Current | Target |
|---|---|---|
| Fail-closed on camera failure | ✅ --on-camera-failure lock (20s grace) |
— |
| Fail-closed on crash | ✅ Watchdog + crash handler locks before exit | systemd WatchdogSec + sd_notify |
| LLM cannot grant below T0 | ✅ TrustPolicy.fuse() — 4 tiers × 4 verdicts exhaustively tested |
— |
| LLM verdict grammar is strict | ✅ 1 token/line, NFKC, negator rejection, no substring fallback | — |
| Injected OWNER is harmless at T1 | ✅ Grammar → injection screen → TrustPolicy (3 independent defences) | — |
| Biometric egress requires consent | ✅ --llm-accept-egress for cloud; local providers are default |
— |
| Credentials never in argv | ✅ Only --llm-api-key-file (chmod 400) + env; Hermes bearer token same |
— |
| FAR/FRR measurement | ✅ lock-on-absence-replay + CI gates |
LLM backend coverage (v5.5) |
| Face template protection | ✅ chmod 600 after enrollment |
icacls on Windows, HMAC on face_model.yml |
| Threshold integrity | ✅ face_model.json clamped to [20, 100] |
— |
| Liveness detection | ⚠️ Movement-based anti-spoof, off by default | YuNet 5-point landmarks → real yaw |
| Multi-factor | ❌ Webcam only | TESSERA/BLE token |
Exit codes
| Code | Meaning |
|---|---|
0 |
clean shutdown (Ctrl+C / SIGTERM) |
1 |
camera could not be opened, or the loop crashed (screen is locked before exit) |
2 |
no face model and --any-face not given — refusing to run unprotected |
3 |
face_model.yml does not match the digest recorded at enrollment |
Limitations
- Facial recognition uses SFace embeddings (cosine similarity, threshold 0.363) with LBPH as fallback — SFace is published and transferable across lighting; LBPH thresholds are scene-dependent. SFace is the default since v5.2.
- LLM recognition is advisory at T1 (default) — the model can withdraw presence, never grant it. Injection, typographic attack, and a fully compromised model all result in a screen lock at worst, never an unlocked session.
- Video-call coexistence — Camera-busy pause (5 consecutive failures → pause, 15 min cumulative budget). Pause ends the moment the camera is readable again; a one-second grab does not buy 30s of blindness.
- Biometric data is stored in plain files —
face_model.yml(LBPH histograms) andface_model.json(user names) are unencrypted, protected only bychmod 600. HMAC integrity is on the roadmap. Use at your own risk in regulated environments. - Privacy consent —
enroll.pyprompts for consent before capture;--no-consentexists for automated enrollment.
Roadmap
- YuNet + SFace recognition — ✅ shipped in v5.2 (default since v5.2.1)
- LLM & Hermes backends — ✅ shipped in v5.3.0; security-hardened in v5.4.0 with strict grammar, TrustPolicy tiers, and egress gate
- Real labeled dataset — 30–60 min of labeled desk video per lighting condition; publish the FAR/FRR table in this README. The harness is a chassis without an engine until then.
- LLM FAR/FRR measurement —
replay.pyneeds a frame-recorded mode for LLM backends. Thepromotion_gatethresholds inhermes/policy/lock-on-absence.yamlrequire this before T3→T1 promotion is empirical rather than judgement. - HMAC on
face_model.yml— the model file is the product's trust boundary; today it is a plain file. -
icaclsfor Windows model permissions —chmod 600is POSIX-only. - CEF / LEEF output for Splunk native ingestion.
See open issues for the full list.
Contributing
PRs are welcome. Follow the existing style (typed Python, KISS, DRY) and add tests to tests/test_state_machine.py for any new decision path — the architectural guard and the FAR/FRR gates will check the rest.
Areas that need contribution:
- Dataset — labeled video captures (owner present, intruder, empty chair, backlight, oblique angles)
- SFace integration — swap LBPH for
cv2.FaceRecognizerSFwith the published cosine threshold - Liveness — real pose estimation from YuNet 5-point landmarks
- Model integrity — HMAC signing of
face_model.yml - SIEM formats — CEF/LEEF output
License
MIT © 2026 Everton (handnewb)
See LICENSE for the full text.