Skip to content

TST Assist: core engine, policy pack, and Android surface (M1 foundation) - #1

Draft
captcrock wants to merge 26 commits into
mainfrom
claude/tst-assist-android-taiz9d
Draft

captcrock wants to merge 26 commits into
mainfrom
claude/tst-assist-android-taiz9d

Conversation

@captcrock

@captcrock captcrock commented Sep 7, 2026 •

Copy link
Copy Markdown

What this is

The first build of TST Assist per docs/tst-assist-android.md (v2.0 plan, paste ends at §9.4 S1). A pure-JVM Kotlin core, built and tested, plus the Android side written against framework APIs. CI runs the JVM suite and a real assembleDebug; both are green.

In this PR

core/ (JVM, 254 tests, ./gradlew :core:test)

  • Action grammar (plan §7): closed verb set, strict parser, first non-empty line only, hints must exist in the current observation.
  • Observation model: node filter with window z-order and dedupe (C4), coverage detector (C1), session-stable hint table with a one-step quarantine on freed numbers (C3), screen fingerprint for the stale-hint check, paging at 60 with more. Labels render as one bounded escaped token so screen text can never open or close the <<OBS block.
  • Policy pack (plan §4): policy/policy.yaml as ordered data rules; policy/cases.yaml conformance suite (every rule has a case); Kotlin enforcer with the silent / once / every / refused gate; sensitive controls in EN and ES.
  • Task loop: step budget, loop detector, one repair then stop-and-ask, task grant once, a card per Tier 2 action, stale-screen refusal, kill switch polled before every step and execution, intent-lock re-confirm.
  • Injection suite over the loop: hostile screen text, prose and JSON replies, multi-line replies, invented hints, non-allowlisted apps, app drift, keyguard, missing grant. Nothing executes beyond the grammar and the gate.
  • Redactor ported from tstd's logging.py (same five patterns, structure scrub, idempotent).
  • Config presets in tstd's config.yaml shape (desktop presets copied, plus device and home), and the cost meter with tstd's cache-honesty rule (absent cache figure is not zero).
  • Audit: desktop schema v1 and v2 replayed verbatim, a v3 phone migration (actions, approvals, device id), and BEFORE UPDATE / DELETE triggers that make append-only real. CSV and JSONL export with the desktop's columns.
  • Network: bind rules mirroring ws.py and tailscale_bind.py (loopback always, one Tailscale extra, never unspecified), token and hello handshake, Endpoints as the single place a connection is made (hosts not named in config are refused), OpenAI-compatible provider client, and a source scan that fails if anything else opens a connection.
  • Steering: rules-file boundary (ASSISTANT.md, CHARTER.md, profiles/, policy.yaml, escapes and symlinks), instruction stack with token estimate, default ASSISTANT.md.
  • Wire: client-side model of tstd's WebSocket protocol; unknown event kinds dropped and counted, malformed frames counted, approve/deny/always-allow messages matching approval.ts, sequence tracking, checked against TST Desk's own protocol-fixtures.json.

app/ (Android, framework APIs only, assembleDebug green in CI, not yet run on a phone)

  • Accessibility service: tree walker with stable identities, observer, executor (accessibility actions first, gesture fallbacks second).
  • Approval surface: overlay highlight and card plus a notification with Approve and Deny; timeout is deny.
  • Kill switch: Quick Settings tile and Stop on the task notification.
  • Notification listener with inline reply; assistant-role services; Keystore-wrapped secret store; SQLite executor; foreground task service; settings activity with a policy self-check.
  • Task wiring: cloud planner (brain tier, metered, spend cap ends the turn), audit listener (every step, approval and model call is an INSERT), planner selection that says plainly what is missing.

docs/: the plan as supplied, a decision log (TM-001 to TM-009), README.

Still landing on this branch

  • docs/reality-check.md: the plan's claims checked line by line against TST Desk's code, plus the counterpoints that survived an adversarial review. The review is running.

Not done

  • Device mode (on-device model), voice, vision fallback, Home-mode session UI: later milestones per the plan.
  • Nothing has run on a phone yet. The APK builds in CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ

- Closed verb grammar (plan §7): strict parser, first non-empty line only,
  one action per turn, hints checked against the current observation
- Observation model: node filter with z-order and dedupe (C4), coverage
  detector (C1), session-stable hint table with quarantine (C3), screen
  fingerprint, paging at 60 with more
- Formatter renders the <<OBS ... OBS>> block; labels are one bounded,
  escaped token so screen text can never open or close the block

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
- policy/policy.yaml: verb tiers as ordered data rules (plan §4), app
  allowlist, sensitive controls in EN and ES, settings packages
- policy/cases.yaml: shared conformance cases; every rule has one
- PolicyEnforcer: pure rule walk plus the gate (grant / card / refuse)
- Ports the phone implements: Observer, Planner, Executor,
  ApprovalSurface, KillSwitch, SqlExecutor, SecretStore

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
- Accessibility service: tree walker with stable identities, observer,
  executor (accessibility actions first, gestures second, plan §7)
- Approval surface: overlay highlight and card plus notification with
  Approve and Deny; timeout is deny (plan §4)
- Kill switch: Quick Settings tile and Stop action on the task notification
- Notification listener with inline reply; assistant role services;
  Keystore-wrapped secret store; SQLite executor; foreground task service;
  settings activity with the policy self-check
- Type-checked against the API 36 framework jar (tools/androidcheck);
  assembleDebug still needs the SDK

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
…t the root

CI's core job sets ANDROID_HOME="" to force the SDK-less path, which the
settings script converted to a File. The app job failed because
org.jetbrains.kotlin.android was requested with a version while the Kotlin
Gradle Plugin was already on the root classpath from kotlin.jvm.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
Hostile screen text, prose and JSON replies, multi-line replies, invented
hints, non-allowlisted apps, app drift, keyguard, and a missing task grant:
in every case nothing executes beyond the grammar and the policy gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
The Kotlin Android plugin and the Android Gradle Plugin must load in one
classloader; with KGP at the root and AGP only in :app the app build
failed with NoClassDefFoundError com/android/build/gradle/BaseExtension.
AGP is added to the root buildscript classpath only when settings found an
SDK, so the SDK-less core build stays free of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
…rovals and append-only triggers; AuditStore with redaction and desktop-shaped exports
…r client) and steering (rules boundary, instruction stack)
- CloudPlanner: one brain-tier chat call per step, metered; spend cap
  turns into an ask line so the turn ends cleanly
- AuditListener: every step, approval and model call is an INSERT through
  the redactor; unparsed replies are recorded too
- Planners: picks cloud-key, home or device from the active preset and says
  plainly what is missing (key, slug, unbuilt device mode)
- Graph: config presets, Endpoints allowlist, audit store with migrations,
  seeded agent-unwritable rules dir with RulesBoundary, per-device id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
okhttp-android 5.5.0 requires compileSdk 37, beyond AGP 8.13's maximum of
36, and drags in AndroidX. The plain JVM artifact runs fine on Android and
keeps the dependency surface to what core actually uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
The net module's source scan (the no-telemetry promise) caught the wire
client holding its own OkHttpClient factory. It now asks Endpoints for the
WebSocket client, so the host is checked against config before any
connection exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
- Endpoints never follows redirects, refuses cleartext outside loopback and
  the tailnet, pins MagicDNS names to tailnet addresses, and judges every
  network hop; hosts are recorded once per call
- MagicDNS names classify as tailnet; tailnet tiers send no key unless
  bound; credential routing mirrors tstd (an OpenRouter key only ever goes
  to OpenRouter); a key over plain http to a third party fails validation
- Fingerprints fold actionable labels and toggle state so a recycled row
  cannot be tapped after its contents changed
- notif list becomes a NOTIF data block for the model; notification verbs
  are judged by the posting app (allowlist and intent lock apply)
- recursive_triggers on, so INSERT OR REPLACE cannot rewrite audit rows
- ClientMessage.toString never shows the hello token
- Conformance drives the enforcer's own fact derivation; the outbound scan
  covers app sources too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
- TreeObserver builds observe() and fingerprint() from one filtered screen
  (no overlay windows), so an approved action no longer fails the stale
  check because our own card was on screen; FLAG_SECURE is not inferred
- NodeExecutor re-checks the target's label before acting, refuses text
  into non-editable nodes, marks the paste clip sensitive and clears it,
  and drags as one continued stroke instead of a second pointer at (0,0)
- TreeWalker derives a label for clickable containers whose words live in
  a child, so a Delete row is a sensitive control
- Cancellation is rethrown in the task service and the planner; the
  service only stops itself for its own start; ask lines are quoted safely
- Keystore: an unreadable secret is forgotten, not a crash; key() synchronized
- Rules boundary root is the files dir; audit detail is bounded; the
  notification listener reports posters; goal apps never widen to the
  whole allowlist; the direct-to-box planner is named home-direct
- Settings: the person's config.yaml overrides the shipped presets; a
  Home box section (MagicDNS URL, model, optional key), a one-call brain
  test, and a way back to the shipped presets

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vyo2hxtfWppNVyy99Q8GmZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants