Pause capture on lock screen and configurable idle state #70

Merged
lost-rob0t merged 17 commits from agent/issue-18-lock-idle-state into main 2026-08-12 20:58:44 +00:00
lost-rob0t commented 2026-08-12 20:00:12 +00:00 (Migrated from github.com)

Closes #18.

Problem and scope

Implement authoritative lock-screen and configurable idle-state handling without creating a second capture/privacy state machine. Lock state is a security boundary: startup, unknown state, source failure, reconnect, and stale unlock evidence all fail closed.

Lock-source architecture

  • Added a typed project-owned session-safety boundary with normalized lock observations, fixed source IDs/revisions, bounded timestamps, and fixed failure codes.
  • Linux/Xorg lock authority is the target org.freedesktop.login1.Session on the local system D-Bus.
  • The logind adapter resolves the configured session through GetSession, validates the returned object path, and queries LockedHint using bounded fixed-argument busctl --system calls without a shell.
  • LockedHint=false is required to establish unlocked state. A target-session Lock signal fails closed immediately; an Unlock signal alone never authorizes capture and becomes unknown until a fresh LockedHint=false observation is obtained.
  • Unrelated session events are ignored. Disconnect, malformed replies, timeout, permission failure, session disappearance, and stale unlocked observations become sanitized unknown/failure states.

Idle-source architecture

  • Idle handling is orthogonal to lock handling and disabled by default to preserve existing profile behavior.
  • ActivityWatch remains the raw AFK parsing boundary; session safety consumes only normalized idle and opt-in normalized idle.seconds metadata.
  • Added an Xorg fallback using fixed xprintidle invocation with no shell, bounded output/time/value validation, and clean unavailable/malformed results.
  • Missing idle support never disables lock protection.
  • Current ActivityWatch is preferred; Xorg is fallback. Current source conflict resolves conservatively to idle; stale observations are not accepted as current evidence.

Lock vs idle precedence

Lock is stronger than idle:

  • locked + any idle state => blocked by lock;
  • unknown lock + any idle state => fail closed;
  • unlocked + idle => automatic idle pause when configured;
  • unlocked + active => eligible only through fresh lifecycle/policy authorization;
  • privacy/manual pause remain stronger independent controls and are not released by automatic activity/unlock handling.

Startup and daemon restart while locked

Session-safety preflight runs before lifecycle recording authorization. Startup begins with policy lock asserted and capture blocked until current lock state is known. Already-locked and unknown startup states enter paused/non-capturing behavior, so restart while locked has no transient capture window. Lock resolution remains mandatory even if idle handling is disabled or unavailable.

Generation and cancellation semantics

  • Reuses #7 capture-generation/cancellation semantics rather than adding another generation model.
  • Automatic lock/idle pause invalidates the active capture generation, allocates a fresh paused generation, drains/cancels old work, and clears old-generation volatile buffers.
  • Reuses #17 session_locked policy gate; changing lock state advances policy generation.
  • Old capture tokens and old policy authorizations remain stale after lock -> unlock. Unlock/activity can only authorize new-generation work after fresh policy evaluation.
  • Persistence remains linearized by the existing gate commit lock; lock-before-persistence rejects stale frames and downstream stages re-check generation after processing.

Rapid race handling

Coverage includes capture authorized -> lock -> unlock -> stale persistence callback, lock during downstream processing, duplicate transitions, and old-generation callbacks after resume. Stale frames cannot persist or continue to OCR/index/summarization/provider boundaries after invalidation.

Configuration impact

Added immutable validated capture.idle settings:

  • enabled (default false)
  • pause_capture (default true)
  • threshold_seconds (> 0, <= 24h; exact threshold is idle)
  • resume_behavior (immediate, active-grace, manual)
  • active_grace_seconds (bounded <= 5m)
  • max_observation_age_seconds (bounded <= 5m)

Active-grace duration uses an injected monotonic clock. Restrictive reloads reclassify the current observation and invalidate authorization immediately where required. More-permissive reloads cannot revive an old generation. The configuration change is additive/defaulted under the current schema.

Audit and status

Session-safety audit/status objects contain only normalized control state, fixed source IDs/revisions, generation, timestamps, threshold/health, and fixed failure codes. Raw D-Bus payloads, ActivityWatch payloads, usernames, titles, domains, command lines, OCR, screenshots, and tokens are excluded. Existing closed lifecycle audit transition mappings for lock/unlock/idle/active are reused.

Security boundaries

  • Lock/idle modules report control state only; they do not capture pixels, persist storage, or invoke providers.
  • Policy denial covers screenshot and remote-provider eligibility.
  • Lifecycle generation guards cover persistence and downstream processing.
  • Unknown/stale/reconnecting lock state remains non-permissive.
  • Multi-session filtering binds events to the resolved target session.
  • Synthetic security fixtures seed recognizable private values and assert they do not escape sanitized status/error/audit surfaces.

TDD evidence

RED:

  • Test-only head e9b0cb6c14718dae8ee6b91f3f413a28826ebc57.
  • Actions run 31635438606 passed formatting, Ruff, and ShellCheck, then strict Pyright failed specifically because the new typed local_recall.session.safety production API did not exist.
  • After initial implementation, the full suite reached 536 passing / 3 failing tests. Those failures exposed a restrictive idle-threshold reload bug plus two deterministic test-clock/setup problems. The reload regression test was retained and production now reclassifies the current observation before reconciliation.

GREEN:

  • Production reconstruction/finalization run 31639771804 passed the exact canonical ./scripts/check before committing the production tree.
  • Exact PR head 92ea3ac6533c6c33b5f590ffb4e8a4e46bd4eccd passed normal CI run 31639943187:
    • Checks Ubuntu 22.04: green;
    • Checks Ubuntu 24.04: green;
    • Canonical repository gate: green;
    • Failure propagation: green.
  • Canonical gate: formatting clean, Ruff clean, strict Pyright 0 errors, 0 warnings, 539/539 tests passed, 8 failure modes verified, repository policy checks passed, plus the repository's current security scan layers.

Acceptance-criteria mapping

  • No capture while locked/unknown: startup preflight + automatic lifecycle block + policy lock gate.
  • Lock cancels prior-session work: capture generation invalidation + policy-generation invalidation.
  • Lock after pixel acquisition but before persistence: stale-generation persistence rejection.
  • OCR/index/summarization/provider work after lock: downstream generation/policy rechecks reject old work.
  • Rapid lock/unlock: unlock never revives old generation/authorization.
  • Restart while already locked: current LockedHint queried before recording can begin.
  • ActivityWatch unavailable: Xorg idle fallback or unknown idle; lock protection remains independent.
  • Idle threshold/resume: bounded typed config, exact-threshold tests, fixed monotonic clock, explicit immediate/grace/manual semantics.
  • Source failures/reconnect: fail closed until fresh current lock truth is established.
  • Audit/status: sanitized typed control-only surfaces.

Known limitations

  • Wayland portal capture is out of scope for #18.
  • LockedHint correctness depends on the desktop/session locker cooperating with logind; Local Recall responds to missing/uncertain truth by failing closed.
  • Xorg idle fallback requires the X Screen Saver extension and xprintidle; absence affects idle fallback only, never lock blocking.

A successful unlock or active-idle observation does not resurrect previously authorized work. New capture requires fresh lifecycle and policy authorization.

Missing idle support does not weaken lock-screen blocking.

Closes #18. ## Problem and scope Implement authoritative lock-screen and configurable idle-state handling without creating a second capture/privacy state machine. Lock state is a security boundary: startup, unknown state, source failure, reconnect, and stale unlock evidence all fail closed. ## Lock-source architecture - Added a typed project-owned session-safety boundary with normalized lock observations, fixed source IDs/revisions, bounded timestamps, and fixed failure codes. - Linux/Xorg lock authority is the target `org.freedesktop.login1.Session` on the local system D-Bus. - The logind adapter resolves the configured session through `GetSession`, validates the returned object path, and queries `LockedHint` using bounded fixed-argument `busctl --system` calls without a shell. - `LockedHint=false` is required to establish unlocked state. A target-session `Lock` signal fails closed immediately; an `Unlock` signal alone never authorizes capture and becomes unknown until a fresh `LockedHint=false` observation is obtained. - Unrelated session events are ignored. Disconnect, malformed replies, timeout, permission failure, session disappearance, and stale unlocked observations become sanitized unknown/failure states. ## Idle-source architecture - Idle handling is orthogonal to lock handling and disabled by default to preserve existing profile behavior. - ActivityWatch remains the raw AFK parsing boundary; session safety consumes only normalized `idle` and opt-in normalized `idle.seconds` metadata. - Added an Xorg fallback using fixed `xprintidle` invocation with no shell, bounded output/time/value validation, and clean unavailable/malformed results. - Missing idle support never disables lock protection. - Current ActivityWatch is preferred; Xorg is fallback. Current source conflict resolves conservatively to idle; stale observations are not accepted as current evidence. ## Lock vs idle precedence Lock is stronger than idle: - locked + any idle state => blocked by lock; - unknown lock + any idle state => fail closed; - unlocked + idle => automatic idle pause when configured; - unlocked + active => eligible only through fresh lifecycle/policy authorization; - privacy/manual pause remain stronger independent controls and are not released by automatic activity/unlock handling. ## Startup and daemon restart while locked Session-safety preflight runs before lifecycle recording authorization. Startup begins with policy lock asserted and capture blocked until current lock state is known. Already-locked and unknown startup states enter paused/non-capturing behavior, so restart while locked has no transient capture window. Lock resolution remains mandatory even if idle handling is disabled or unavailable. ## Generation and cancellation semantics - Reuses #7 capture-generation/cancellation semantics rather than adding another generation model. - Automatic lock/idle pause invalidates the active capture generation, allocates a fresh paused generation, drains/cancels old work, and clears old-generation volatile buffers. - Reuses #17 `session_locked` policy gate; changing lock state advances policy generation. - Old capture tokens and old policy authorizations remain stale after lock -> unlock. Unlock/activity can only authorize new-generation work after fresh policy evaluation. - Persistence remains linearized by the existing gate commit lock; lock-before-persistence rejects stale frames and downstream stages re-check generation after processing. ## Rapid race handling Coverage includes capture authorized -> lock -> unlock -> stale persistence callback, lock during downstream processing, duplicate transitions, and old-generation callbacks after resume. Stale frames cannot persist or continue to OCR/index/summarization/provider boundaries after invalidation. ## Configuration impact Added immutable validated `capture.idle` settings: - `enabled` (default false) - `pause_capture` (default true) - `threshold_seconds` (> 0, <= 24h; exact threshold is idle) - `resume_behavior` (`immediate`, `active-grace`, `manual`) - `active_grace_seconds` (bounded <= 5m) - `max_observation_age_seconds` (bounded <= 5m) Active-grace duration uses an injected monotonic clock. Restrictive reloads reclassify the current observation and invalidate authorization immediately where required. More-permissive reloads cannot revive an old generation. The configuration change is additive/defaulted under the current schema. ## Audit and status Session-safety audit/status objects contain only normalized control state, fixed source IDs/revisions, generation, timestamps, threshold/health, and fixed failure codes. Raw D-Bus payloads, ActivityWatch payloads, usernames, titles, domains, command lines, OCR, screenshots, and tokens are excluded. Existing closed lifecycle audit transition mappings for lock/unlock/idle/active are reused. ## Security boundaries - Lock/idle modules report control state only; they do not capture pixels, persist storage, or invoke providers. - Policy denial covers screenshot and remote-provider eligibility. - Lifecycle generation guards cover persistence and downstream processing. - Unknown/stale/reconnecting lock state remains non-permissive. - Multi-session filtering binds events to the resolved target session. - Synthetic security fixtures seed recognizable private values and assert they do not escape sanitized status/error/audit surfaces. ## TDD evidence RED: - Test-only head `e9b0cb6c14718dae8ee6b91f3f413a28826ebc57`. - Actions run `31635438606` passed formatting, Ruff, and ShellCheck, then strict Pyright failed specifically because the new typed `local_recall.session.safety` production API did not exist. - After initial implementation, the full suite reached 536 passing / 3 failing tests. Those failures exposed a restrictive idle-threshold reload bug plus two deterministic test-clock/setup problems. The reload regression test was retained and production now reclassifies the current observation before reconciliation. GREEN: - Production reconstruction/finalization run `31639771804` passed the exact canonical `./scripts/check` before committing the production tree. - Exact PR head `92ea3ac6533c6c33b5f590ffb4e8a4e46bd4eccd` passed normal CI run `31639943187`: - Checks Ubuntu 22.04: green; - Checks Ubuntu 24.04: green; - Canonical repository gate: green; - Failure propagation: green. - Canonical gate: formatting clean, Ruff clean, strict Pyright `0 errors, 0 warnings`, 539/539 tests passed, 8 failure modes verified, repository policy checks passed, plus the repository's current security scan layers. ## Acceptance-criteria mapping - No capture while locked/unknown: startup preflight + automatic lifecycle block + policy lock gate. - Lock cancels prior-session work: capture generation invalidation + policy-generation invalidation. - Lock after pixel acquisition but before persistence: stale-generation persistence rejection. - OCR/index/summarization/provider work after lock: downstream generation/policy rechecks reject old work. - Rapid lock/unlock: unlock never revives old generation/authorization. - Restart while already locked: current `LockedHint` queried before recording can begin. - ActivityWatch unavailable: Xorg idle fallback or unknown idle; lock protection remains independent. - Idle threshold/resume: bounded typed config, exact-threshold tests, fixed monotonic clock, explicit immediate/grace/manual semantics. - Source failures/reconnect: fail closed until fresh current lock truth is established. - Audit/status: sanitized typed control-only surfaces. ## Known limitations - Wayland portal capture is out of scope for #18. - `LockedHint` correctness depends on the desktop/session locker cooperating with logind; Local Recall responds to missing/uncertain truth by failing closed. - Xorg idle fallback requires the X Screen Saver extension and `xprintidle`; absence affects idle fallback only, never lock blocking. A successful unlock or active-idle observation does not resurrect previously authorized work. New capture requires fresh lifecycle and policy authorization. Missing idle support does not weaken lock-screen blocking.
Sign in to join this conversation.
No description provided.