Skip to content

Biometric app lock: cover the Console with a native lock screen after N minutes in the background #145

Closed

Description

@hotlong

repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

Ruled: 6593856498 · letter A · 2026-11-03T02:26Z
Blocked-by: #144

Context

The 2.0 shell keeps lib/biometric-auth.ts, lib/app-lock.ts and stores/security-store.ts from 1.x, with their tests, but nothing uses them yet. The lock is one of the native features that make the review build more than a web frame (docs/DEPLOYMENT.md, App review).

Ask

  • A setting for the lock: off / on, with a timeout. It needs a native settings entry; a bridge method setAppLock lets the Console offer it from its profile page.
  • On return from the background past the timeout, a native overlay covers the WebView until Face ID / Touch ID / fingerprint succeeds, with a device-passcode fallback.
  • The WebView stays mounted under the overlay; the page is not reloaded.
  • The app-switcher snapshot is blurred while locked (iOS AppState inactive, Android FLAG_SECURE optional).

Acceptance

  • Unit tests cover the timeout logic and the overlay states.
  • docs/HYBRID-SHELL.md documents the bridge method.
  • Manual check on a device: lock, unlock, fallback, snapshot.

Activity

  1. added
    enhancementNew feature or request
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review
    on Nov 2, 2026
  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    决策:应用锁的开关,网页能不能改?

    一句话问题: 本卡让 Console 网页经桥接调用 setAppLock 开关应用锁。这样一来,任何能调桥接的页面都能悄悄关掉用户自己设的锁。

    Governing text:

    • docs/HYBRID-SHELL.md 〈Known limits〉:"Old Android WebViews cannot tell an iframe's bridge message from the page's … the origin check sees the Console's origin for any frame"。
    • 同文〈Navigation policy〉:Sub-frame (embedded map, preview) → load。
    • 车道章程 lanes/mobile.md:「会话转移、桥接新增写能力、导航策略放行新来源,皆属安全边界,恒在人工地板」。

    前提(带 re-check):

    • 现有桥接 5 个方法全是只读或导航,无一改安全设置。re-check:grep -n -A7 "BRIDGE_METHODS = \[" lib/shell/bridge/protocol.ts
    • 锁逻辑在 lib/app-lock.ts,今无调用者。re-check:git grep -n "startAppLockMonitor\|unlockApp" -- app components hooks

    选项:

    选项 做什么 客户可感知后果 工作量
    A 原生设置页唯一入口;桥接只加只读 getAppLock() 与 openAppLockSettings()(弹原生页,改动须过生物识别) 用户在 Console 资料页点「应用锁」跳到原生设置;网页无法静默关锁 mobile 车道 · 1 PR · 无先例
    B 照卡面:桥接 setAppLock(on/off, timeout) Console 内一键开关;页面或旧安卓上的 iframe 可静默关锁 mobile 车道 · 1 PR · 无先例
    C B,但关锁或放宽超时须先过生物识别 网页能发起,关锁仍要用户本人刷脸 mobile 车道 · 1 PR · 无先例

    业务含义: A 像银行 App,改安全设置只能在 App 自己的设置页。B 像网页能改你手机锁屏。C 是网页能发起改动,但要你本人确认。

    四轴(业务立场):

    • 长远合理性(权重最高):安全开关留在原生、桥接只读,是两年后主流宿主壳的形态。Hotwire Native 与 Capacitor 的生物识别插件都只把验证或读状态交给网页,不交「关掉保护」。A 不增特例。
    • 业务拉动: 今天零用户(从未上架),拉动来自审核材料 docs/DEPLOYMENT.md(App review)。它要的是锁存在,不要求网页能改。
    • 防 AI 犯错: B 下 AI 写的 Console 代码若误调 setAppLock(false),用户毫无察觉,是静默失守。A 下结构上没有这个调用。C 下有响亮拦截(弹生物识别)。
    • 创业阶段不扩散: A 新增的写面为零;B 与 C 各多一个永久维护的写方法。
    os-decision-facets
    ① 长远:A 缩小写面;B/C 增加一个安全写方法
    ② 拉动:零上架用户;审核只要锁存在
    ③ 防错:A 结构上不可误关;C 响亮;B 静默
    ④ 不扩散:A 无新写面;B/C 各一永久义务
    Prior rulings read: setAppLock|app lock → 1 hit (#145 itself); ADR none; thread: none
    

    推荐:A。

    • 置信缺口:未实测 Console 资料页是否已有放入口的位置。
    • 不取 B:静默关锁不可接受。
    • 不取 C:多一个写方法,却只换来少点一次。
    • 自检:只看①选 A;②③④ 是否翻转:否。

    裁后执行: 席位把卡面 Ask 第一条改为 A 的形状(在卡上留改法评论),派 os-dev。验收后在 objectstack 立 Console 消费卡(资料页入口,has('openAppLockSettings'))。

    Analysis-tier: default (seat tier)

  3. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    Ruling: batch #1 item 2 · letter A · maintainer 「同意」 2026-11-03T02:26Z

    The maintainer accepted the batch's recommendations in the session session_01QSeKYnW1Xf6HDSWz1eGAqv. The presentation (item 2) put A as the recommendation, and the reply 「同意」 applies to it.

    Option A: the app-lock setting is changed only on a native settings screen. The bridge exposes only read-only getAppLock() and openAppLockSettings(), which opens the native screen; changing the setting there requires biometrics. ⛔ No setAppLock write method.

    Effort for A (card's column): mobile lane, 1 PR. Precedent: #146 (scan(), +903/−37).

    Next: the card moves to pm:queue. The seat dispatches A. After acceptance it files the Console consumer card (profile-page entry when has('openAppLockSettings')).

  4. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review
    pm:queueReady for the PM dispatch loop
    on Nov 3, 2026
  5. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    Claim: PM loop round 2
    Session: session_01QSeKYnW1Xf6HDSWz1eGAqv
    Account: os-justin
    Branch: claude/issue-145-app-lock
    Worktree: mobile-issue-145
    Domain: n/a — single-lane repo (repo:mobile)
    Seat: repo:mobile#1
    File surface: lib/biometric-auth.ts, lib/app-lock.ts, stores/security-store.ts, a native lock overlay and settings screen under components/ or app/, app/_layout.tsx, lib/shell/bridge/** (getAppLock, openAppLockSettings, read-only), locales/*.json (all ten), __tests__/**, docs/HYBRID-SHELL.md, .changeset/145-app-lock.md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default tier (opus) — no path-derived mandate, effort: high
    Clause-②: no
    Metadata-protocol: none
    Responsibility: n/a — not a defect card
    Thread-read: 6593856498
    Premise: holds. On main, lib/app-lock.ts and lib/biometric-auth.ts exist with no app importer, and BRIDGE_METHODS has no app-lock method. Ruling 6593856498: option A (⛔ no setAppLock).
    Serial constraints cleared: #145 and #147 are dispatched in the same round and both touch lib/shell/bridge/** and docs/HYBRID-SHELL.md. That is ordinary concurrency; the PR that lands second reconciles.

  6. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv
    os-dev-report

    {
      "issue": 145,
      "status": "done",
      "branch": "claude/issue-145-app-lock",
      "pr": "https://github.com/objectstack-ai/mobile/pull/181",
      "session": "session_01QSeKYnW1Xf6HDSWz1eGAqv",
      "premise_still_valid": true,
      "summary": "Implemented ruling A. The app lock is a pure machine in stores/security-store.ts (unlocked, covered, locked, unlocking) plus side effects in lib/app-lock.ts. Those are: a SecureStore record read synchronously before the first frame, failing closed on a bad record or a throwing read; an AppState monitor that locks only on a return from background past the timeout, or on a cold start; the unlock prompt with passcode fallback, saying 'Unlock ObjectOS'; and changeAppLockSettings, the only writer, which authenticates before it stores. The native screen app/app-lock-settings.tsx is reached from a new 'App lock' row in the switcher and from the bridge's openAppLockSettings(). getAppLock() returns { enabled, timeoutSeconds }. Both pass the origin gate, and no bridge method changes the lock. The overlay is a sibling view above the stack in app/_layout.tsx, not a route, so the WebView stays mounted, is not reloaded, and stays in the ready state. While locked, the bridge refuses methods that open native UI. Snapshot protection uses expo-screen-capture: iOS blurs the snapshot natively, and Android FLAG_SECURE IS set while the lock is on. All strings, including the Face ID usage text, are in all ten locales. Premise measured at base 668ed7e: expo-local-authentication is ~55.0.14 (installed 55.0.14); the lock modules had no app importer; BRIDGE_METHODS had no lock method; MDM lockServer is untouched. Blocker #144 was merged 2026-11-02.",
      "tests": "On head 654490f: npx tsc --noEmit exit 0; npx eslint . --ext .ts,.tsx exit 0; npx jest --ci --coverage --maxWorkers=2 exit 0, Test Suites 47 passed / 47, Tests 915 passed / 915 (base 668ed7e: 44 / 821). Coverage: app-lock.ts 98.38% statements, security-store.ts 100%, AppLockOverlay.tsx 100%. Cold variant (--no-cache --runInBand, one file per run, beforeAll warm-up): AppLockOverlay 11/11 exit 0 10.4 s; AppLockSettingsScreen 13/13 exit 0 11.0 s; ConsoleScreen 16/16 exit 0 18.8 s (the slowest single test is 574 ms, a pre-existing one). Ablation of 'no bridge method changes the lock', run on committed head 654490f with ablation-replace.mjs (anchor hit, blob changed, restore proven against the HEAD blob): leg A planted setAppLock in BRIDGE_METHODS (protocol.ts blob e1ec622 to 241b889), and bridge-protocol.test went red, 1 failed / 24 passed. Leg B added a lock-writing case 'updatePreferences' in the handler (blob 5397738 to a500405), wired to the store commit in app/console.tsx (blob 27eadd2 to adc21da). ConsoleScreen 'no bridge method changes the lock' went red: expected {enabled:true,timeoutSeconds:300}, received false. Both files were restored to their HEAD blobs, and git status was empty. After restore, both suites passed 41/41. The first leg-B attempt was a refused no-op (the replacement contained its anchor); it was re-anchored and rerun. Exit codes are recorded in the scratchpad issue-145/.",
      "gates": "Local, exit captured before any pipe, under os-verify-lock (VERDICT command-exit 0 each): tsc 0, eslint 0, jest coverage 0, all at 654490f. The dispatch-gates derivation is not applicable: objectstack-ai/mobile has no scripts/pm/dispatch-gates.mjs. I derived the CI families by hand from .github/workflows: ci.yml runs npx jest --ci --coverage, and lint-typecheck.yml runs tsc and eslint; both run on pull_request, push and merge_group. The eas-* workflows are not PR gates. I read PR #181's check-runs on 654490f once: none were registered yet, so CI is NOT MEASURED (reason: not yet started when read). I did not poll.",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm (Clause-②: no)",
      "mcp_calls": "0",
      "api_writes": "2 relay writes: (1) pr_create, sent as repository_dispatch fw-20261103T030443Z-8962dc, which ran POST /repos/objectstack-ai/mobile/pulls (draft, no labels, no assignees); the read-back stored 10080 bytes, identical to what was sent. (2) This report comment, POST /repos/objectstack-ai/mobile/issues/145/comments via post-stamped.mjs. Not REST: 5 git pushes of claude/issue-145-app-lock (the empty-branch probe, then 4 commits).",
      "open_questions": [],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: seat (PM) · the Console consumer card, a profile-page App lock entry when has('openAppLockSettings'), which the ruling already schedules after acceptance · noted, not filed",
        "carrier: none · jest.setup.ts mocks react-native AppState without a default export and mocks expo-local-authentication without getEnrolledLevelAsync/SecurityLevel. So import { AppState } from 'react-native' is undefined in tests, and a property added to a shared mock later never reaches code that uses import * as. New tests use per-file factories, as managed-config/native.test.ts does. Test-infra observation, not a defect class · noted, not filed",
        "carrier: none · docs/DEVICE-TESTING.md has no app-lock section; the five device checks are in PR #181's 「需要你做的」 · noted, not filed",
        "carrier: none · ROADMAP.md Phase 4 still lists 'Biometric app lock' as future work, the same as the landed scan() · noted, not filed"
      ],
      "deviations": "(1) The changes go beyond the claim's file surface. app/console.tsx is where the bridge capabilities are wired. components/shell/ConnectionSwitcher.tsx holds the settings entry. components/shell/ConsoleWebView.tsx gets one line: the Android back handler steps aside while locked. app.config.ts gets the localized NSFaceIDUsageDescription and blocks the three permissions expo-screen-capture declares for its unused screenshot listener. package.json and pnpm-lock.yaml add expo-screen-capture ~55.0.18 (the SDK 55 bundled range is ~55.0.14), the only producer of a native switcher blur and FLAG_SECURE. Also new: __tests__/lib/biometric-auth-level.test.ts. The PR body names all of these. (2) getAppLock() names its field timeoutSeconds, not a bare 'timeout', so the unit is in the name. (3) Changing the setting accepts the device passcode as fallback, as unlocking does; this is an Acceptance note in the PR. (4) 「需要你做的」 is one action (run the device checks) with the five checks as sub-items. This reconciles the dispatch's list with os-dev's exactly-one-action rule. (5) The jest gate ran with --maxWorkers=2 under the shared-container rule; the dispatch spelled bare npx jest --ci --coverage. (6) Commit trailers use AGENTS.md's model-free pair (Claude-Session and Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By. The PR footer uses the AGENTS.md session-URL form. (7) No device check is claimed as verified."
    }
  7. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    ACCEPT → PR #181 (head 77ded7b). It is over 3000 lines, so the maintainer merges it. The Console consumer card is filed in objectstack. A native rebuild is owed (expo-screen-capture).

  8. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    Landed. #181 merged through the merge queue (hotlong enqueued it). The card closed completed via Fixes #145, and pm:dispatched was removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions