- Rust 81.1%
- TypeScript 14%
- CSS 1.3%
- Shell 1.2%
- PowerShell 1.2%
- Other 1.2%
| .claude | ||
| apps/admin | ||
| collectors | ||
| contracts | ||
| crates | ||
| docs | ||
| extension | ||
| packages | ||
| scripts | ||
| spikes | ||
| tests/e2e | ||
| .gitignore | ||
| .npmrc | ||
| Cargo.lock | ||
| Cargo.toml | ||
| HANDOFF.md | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| rust-toolchain.toml | ||
| tsconfig.base.json | ||
| vercel.ts | ||
here-telemetry
Work-session telemetry that runs on people's own machines, gates every byte locally before anything can reach the network, and makes what it captures visible to the person being captured.
Start here
Hooking this up to the platform? → docs/PLATFORM-INTEGRATION.md
is the handoff, and it is the only document you need to implement the server side. It carries
the seven ports you write, the invariants you must not break with the test that defends each,
the five endpoints and the idempotency rules that are easy to get wrong, a migration checklist,
and what is deliberately missing. The same document is published as a page for reading:
Ports and Invariants.
Porting the client to another OS? → docs/PORTING.md, capability by
capability across Windows, macOS, X11 and each Wayland compositor, with what changes about the
promises.
Picking the work back up? → HANDOFF.md: where it stands, what is next, and
what is blocked on what.
Want the shape of it rather than the detail? → the
wiki, whose source is
docs/wiki/: how a byte becomes a record, what each signal refuses to do, the
decisions not to relitigate, the bug classes found by running it, and the environment traps. It
explains; this file promises. Where the two disagree, this file wins.
Why it is built this way
- Telemetry Signal Chain — which signals are worth capturing, what they cost in bandwidth, the five-gate model.
- Capture Client v1 — the four-process trust boundary, grant tiers, prior art.
docs/P0-REPORT.md— the six de-risking spikes, what they measured, and the four plan decisions they overturned.docs/legal/monitoring-obligations.md— monitoring-notice law by jurisdiction, with confidence flags.
The invariants
Everything else is detail. These are the claims the code is built to keep, each with the test that defends it:
| Claim | Where it is enforced | Test |
|---|---|---|
| No consent record, nothing stored | here-store::append_event refuses |
events_are_refused_without_consent |
| A collector cannot leak, because it has no network | per-exe outbound Block rule, applied by the supervisor before it starts a collector | here-supervisor::firewall |
| A collector cannot emit what nobody declared | admission joins the generated registry to the grants table | an_undeclared_event_type_is_rejected_over_the_wire |
| A denied app is absent, not masked | per-HWND capture; content fields stripped on suppression | a_denied_app_produces_no_pixels_at_all |
| Suppression is a record, not a hole | gate_state on every row, content fields marked x-content in the schemas |
a_denied_app_is_stored_as_timing_only |
| Secrets never reach disk | L2 runs before the write, not before the upload | corpus: 0% leak, 0% over-redaction |
| No verified indicator, no content capture | the gate holds and opens a gap | content_suppresses_when_the_indicator_reports_itself_covered |
| A sense that reaches past the person who pressed Start does not turn on | every manifest declares subjects; a room signal is refused at grant time, held at the gate and told room_clear: false, until a machine can say the room agreed |
a_room_signal_is_refused_and_an_operator_signal_is_not |
| A signal the machine cannot run says so, and its switch does not pretend | refusals are written to signal_state at startup; the console shows cannot run here with the machine's sentence, and SetGrant answers with it instead of recording a grant |
a_refused_signal_reports_itself_refused_and_cannot_be_switched_on |
| Consent is an act, not a boot step | a session starts with no consent and stores nothing until the person confirms in the console; a console consent under the same policy carries forward; a changed policy needs a fresh act | consent_is_an_act_and_nothing_is_stored_before_it |
| A session's start cannot be back-dated | the session-start record is signed by the device key and the session row carries the same signature; the server checks it like consent | the_session_start_on_the_log_is_signed_and_the_row_carries_the_same_signature |
| Consent cannot be back-dated | signed by a device key derived from the store's key; the public key is in every batch header and the server refuses a consent record that does not verify | a_signed_consent_verifies_and_a_tampered_one_does_not |
| A full store refuses pixels and keeps the timing | with a disk budget set, the gate measures the disk before each frame and suppresses under L1.disk_budget rather than writing until the disk fills |
a_store_at_its_disk_budget_refuses_pixels_and_keeps_the_timing |
| Every line the uploader builds is a valid envelope | the schema admits signal_id, which the uploader always sent; a test parses each built line with the generated envelope type, which refuses unknown fields |
every_line_the_uploader_builds_is_a_valid_envelope |
| A frame crosses the pipe as a PNG, not as base64 | the collector encodes with the gate's settings and sends the pixels as a sidecar frame the record sizes; the gate decodes, then redacts | a_full_size_window_of_text_crosses_in_well_under_a_mebibyte |
| A switch that cannot deliver does not turn on | a desktop that cannot verify the indicator refuses every content signal at grant time, the same set the hold would suppress; window activity is reduced to timing and told so | an_unverifiable_indicator_refuses_every_content_signal_and_reduces_focus |
| A collector's count is what the gate stored | every record is answered stored, rejected or dropped; only stored is emitted, the rest is events_dropped on the heartbeat and collector_dropped in the store |
a_record_the_gate_stored_is_delivered_and_a_rejected_one_is_not |
| A second writer waits instead of failing | busy_timeout=5000 on every connection |
a_second_writer_waits_for_the_first_instead_of_failing |
| A session can be replayed from its log alone | the gate writes session start and end, consent, grant changes, pauses, scope changes and indicator gaps and states as events on the same log as the data; collectors cannot write those types | a_session_can_be_replayed_from_its_log_alone |
| A nap is a record, not a hole | every record carries the collector's boot-clock tick_ms; the gate compares that clock with the process clock and writes a clock.sleep decision when they drift; indicator freshness uses the boot clock |
an_hour_on_the_boot_clock_against_a_second_awake_is_an_hour_asleep_reported_once |
| A record is dated by the collector, and the gate's clock is kept beside it | ts_utc is the collector's at at microsecond resolution; received_at is the gate's write; the envelope carries both as time and gate_time |
a_record_is_dated_by_the_collector_and_the_gates_clock_is_kept_beside_it |
| A still screen is a record, not a hole | the frames collector counts the frames it drops as unchanged and reports the count as here.frame.still.v1, timing only |
a_minute_of_static_screen_is_one_frame_and_one_still_record |
| A day's counts outlive its records | the sweep writes a day digest before expiring anything; retention keeps it, erasure drops it | expiry_keeps_a_day_digest_and_erasure_drops_it |
| Delete means delete, including derived data | tombstones propagate to frames, blobs, digests and the upload queue | deletion_propagates_to_frames_blobs_and_digests |
| A record leaves the device on a timer, and the server on request only | the gate sweeps hourly by the registry's local window, only for records the server confirmed, through the deletion path, sending nothing to the server | local_retention_expires_uploaded_records_and_keeps_the_rest |
| Delete reaches the server copy too | a deletion for anything already uploaded is queued ahead of new traffic and retried until confirmed; the receipt says which | deleting_an_uploaded_record_queues_a_deletion_for_the_server |
| A deletion the server would not honour is never reported as done | refusal is a distinct state from confirmed, and survives the queue marking it sent | a_partly_refused_deletion_is_recorded_as_refused |
| A replayed upload cannot duplicate | batch id as idempotency key, both sides | is_idempotent: a replayed batch stores nothing twice |
| A device may only touch its own records | ownership checked against the credential, never the request body | refuses a device deleting another device's records |
| An unidentified caller can do nothing | every endpoint fails closed before it reads a body | refuses an unauthenticated request before reading the body |
| Looking leaves a mark | reads are logged where they happen, and a read that cannot be logged is refused | lets a failed write through so the caller can refuse the read |
| A new app is never captured silently | the first capture from an app nobody has been told about always raises a card, at every rung of the verbose ladder | a_newly_seen_app_always_gets_a_card |
| There is no keystroke buffer | typed text is read from the focused field at a boundary, never accumulated from key events | fields::a_document_reports_its_length_and_withholds_its_text |
| What you copied is never stored | the clipboard record carries a keyed fingerprint, and the fingerprint is content, so a denied app's copies are not linkable | a_clipboard_record_from_a_denied_app_keeps_only_its_timing |
| Content is encrypted on disk | payloads and blobs are AES-256-GCM under a DPAPI-wrapped key; the index is not, and the docs say so | a_sealed_payload_does_not_contain_the_plaintext |
| An allow rule matches what the collectors actually send | rules are written as file names and collectors report full paths; the comparison handles both | a_rule_written_as_a_file_name_matches_the_full_path_a_collector_reports |
| A frame the pipe would refuse is never built | the collector fits each capture to the stored width and a pixel budget before sending | every_plausible_window_shape_fits_the_pipe_after_fitting |
| Off means off | one press closes the session, and every process is gone within seconds | measured: 9 processes to 0 in ~3s |
| Stopping is recorded, not a gap | the session's end reason names the control that was used | stopped_by_hotkey in the session row |
| A frozen indicator stops content | the hold rule asks whether the last report is recent, not just what it said | an_indicator_that_stops_reporting_stops_being_verified |
| A signal that cannot work here is refused, not switched on to produce nothing | the gate asks here-os at startup and marks the grant refused with the reason |
a_mac_with_nothing_granted_refuses_exactly_what_the_permissions_gate |
| A signal that works here with less in it says so | Capabilities::limitations, separate from the refusals, naming the setting to change |
a_signal_that_still_works_with_less_in_it_is_reduced_and_not_refused |
| A refusal tells you about your machine | the reason for each refusal is true on every platform that can hit it, and names the remedy for each | a_mac_is_told_where_to_go_and_not_about_someone_elses_desktop |
| The clipboard's hash-only rule is one rule, not one per platform | the policy lives in here-os above the OS call, so a port cannot ship a weaker version of it |
a_copied_password_appears_nowhere_in_the_record |
Layout
contracts/ source of truth: schemas, registry, enums, manifest schema
crates/
here-contract GENERATED types, registry and signal catalogue (do not edit)
here-store SQLite + content-addressed blobs; the local source of truth
here-gate the pipeline: admission, L0 scope, L1 suppression, hold, L2, L3
+ here-gated, the gate process
here-ipc framed named-pipe IPC between the four process roles
here-win Windows capture primitives: focus, input counts, secure fields,
per-HWND capture, indicator visibility
here-indicator the pill, the captured-window outline, tray state, UIA provider
here-uploader batching + backoff + idempotency; the only credential holder
here-supervisor starts the roles, enforces per-collector network denial
here-console the desktop app: Tauri v2 host over the gate's control channel
xtask codegen and contracts-lint
collectors/ one independent process per signal, each with its own manifest
(here-collector-browser is launched by the browser, not the supervisor)
extension/ the browser extension: reports the host name of the site in front
packages/ @here/contract, @here/platform-adapter, @here/ui,
@here/console-web (the console's React front-end)
apps/admin Next.js: /v1/batches, /v1/blobs/:hash, /api/stream, live view
spikes/ P0 probes, kept because they double as per-machine acceptance checks
tests/e2e the real gate process over the real pipes
docs/ADRs decisions that overturned something, with the measurement
Build and test
One command, from anywhere. It finds the repo from its own path, runs every stage, and prints a pass/fail line per stage plus the test count:
.\scripts\verify.ps1
Windows PowerShell 5.1 has no &&, which is why this is a script rather than a chained
one-liner. Nothing in it captures anything or touches the network — the tests use temporary
stores and an in-process transport.
On a Mac or a Linux box, where there is no PowerShell and no Win32:
./scripts/verify.sh
It runs the same stages against everything that is not Windows — the contract, the store, the
gate, the uploader, the OS seam and the whole TypeScript half — and prints what it skipped and
why. The two are not interchangeable. verify.sh runs 389 tests — 303 Rust and 86 TypeScript —
and verify.ps1 runs those plus the Windows half; a change that touches a collector, the
indicator or the supervisor is unverified until it has been run on Windows. The script says so in its own summary rather than leaving a
green line to imply otherwise, and it fails if the list of packages it skips has drifted from
the packages that exist.
Individual stages, if you want them separately:
cargo run -p xtask -- contracts-lint
cargo run -p xtask -- codegen-check
cargo test --workspace
pnpm install
pnpm -C packages/platform-adapter exec vitest run
pnpm -C apps/admin exec tsc --noEmit -p tsconfig.json
Regenerate after touching contracts/ or any manifest — codegen-check fails if the
generated files are stale:
cargo run -p xtask -- codegen
Releases
Built artifacts live on the repository's releases page. v0.1.0 carries the macOS app as a
universal build — one download that runs on Apple Silicon and on Intel under Rosetta; the
Windows build is attached to the same tag from a Windows machine. The macOS bundle is signed
ad hoc, which makes it verifiable but is not an identity Gatekeeper accepts, and
modules.signature_ok records false rather than pretending on either platform.
A macOS build you can double-click
./scripts/bundle-macos.sh # this machine's architecture
./scripts/bundle-macos.sh --universal # Apple Silicon and Intel in one bundle
Produces dist/here capture.app — all twelve binaries in Contents/MacOS together, which is
not decoration: the console starts the supervisor from its own directory and the supervisor
starts the collectors from that same directory, so one folder is the layout the code already
expects. This is not an installer; it is that same folder, wrapped so it can be double-clicked
and so macOS has something stable to attach permissions to.
--universal compiles everything twice and lipos each binary, so one download runs on both
architectures rather than making somebody work out which file to pick. It is what releases
carry; a local check does not need it.
The signature is ad hoc — codesign -s -, an identity with nothing behind it. It is worth
being exact about what that does:
| Verifiable | yes: codesign --verify --deep --strict walks all twelve and names anything that changed after the build |
| Named on purpose | each binary carries com.hereandmore.capture.<role> rather than the identifier codesign invents from the filename and a file hash |
| Accepted by Gatekeeper | no: TeamIdentifier=not set and no notarisation, so a downloaded copy is still refused |
| Keeps a permission grant across a rebuild | believed not — TCC keys on the code hash and a rebuild changes it. Stated as belief: checking it means clicking through System Settings twice and that has not been done |
A Developer ID is what changes the last two, and it is on the blocked list.
So Gatekeeper refuses to open it after a download. Either right-click it and choose Open, or:
xattr -d com.apple.quarantine "/Applications/here capture.app"
The app asks for its own permissions, and that is the point of bundling it. A binary run
from a terminal inherits whatever the terminal was granted, which is both too much and the
wrong thing; a bundle has its own identity, so Screen Recording, Accessibility and Input
Monitoring are granted to here capture and to nothing else. Until they are, the Capture screen
lists the three signals that are refused and the setting each one needs.
Run it
The console is the way in: it starts and stops capture itself, so this is the only command a contributor needs.
pnpm -C packages/console-web build
cargo build --release
.\target\release\here-console.exe
The front-end build comes first because here-console compiles the UI into its own binary. A
console that loaded its interface from a writable folder would be a way to put arbitrary code
inside the process that talks to the gate.
Then press Start capture on the Capture tab. It launches the supervisor, which applies the firewall rules and starts the gate, the pill and the collectors. The button waits until the gate actually answers rather than reporting success because a process spawned — allow up to a minute for a first start, since each collector's outbound-block rule is a separate Windows firewall call (measured at ~35s here).
To start and stop the whole tree by hand, without any windows:
.\scripts\start.ps1 -Suffix live -Ingest http://localhost:3000 -Console
.\scripts\stop.ps1
Start-Process is the wrong tool for this and worth knowing why: it gives every console
application its own terminal, so launching six roles that way puts six black windows on screen
and looks nothing like the product. start.ps1 spawns with CREATE_NO_WINDOW and drains each
role's output into logs\<role>.log, which is what the app itself does. It does not apply
the firewall rules — only the supervisor does that — so use the supervisor or the console's
Start button for a run with the network-denial invariant in force.
Every background binary is a windows-subsystem executable in release builds
(#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]), so a release build has
no console to show however it is launched, including a double-click. Debug builds keep theirs,
because that is where development output goes. Check it:
# 2 = GUI (no console window), 3 = console
$exe = ".\target\release\here-gated.exe"
$b = [IO.File]::ReadAllBytes($exe)
$pe = [BitConverter]::ToInt32($b, 0x3C)
[BitConverter]::ToUInt16($b, $pe + 92)
To start the tree without the console:
.\target\release\here-supervisor.exe
The supervisor applies the outbound-block rules first and prints whether they took. Run it elevated: without administrator rights the firewall rules cannot be created, and it will say so and refuse to start the collectors rather than capture under a guarantee that is not true.
To see the indicator without capturing anything, start only the gate and the pill. With no collector running there is nothing to capture, so this is safe to leave up while you look at it:
$env:HERE_DATA_DIR = "$env:TEMP\here-demo"
Start-Process .\target\release\here-gated.exe
Start-Process .\target\release\here-indicator.exe
# bottom-right of the primary display. Ctrl+Shift+Alt+I expands, Ctrl+Shift+Alt+H shrinks.
Get-Process here-gated, here-indicator | Stop-Process # when you are done
Environment:
| Variable | Effect |
|---|---|
HERE_DATA_DIR |
Store location. Defaults to %LOCALAPPDATA%\here\store. |
HERE_GRANTS |
Development and test override: consent from the environment, recorded as such. Unset, a session starts from the consent the person last gave in the console under this policy, or from nothing — no consent row, nothing stored — until they act. |
HERE_INGEST_URL |
Where the uploader ships. Unset means dry-run — batches are built and counted, never sent. |
HERE_UPLOAD_ONCE |
One upload pass, then exit. |
HERE_CHANNEL_SUFFIX |
Suffixes the pipe names, so two instances can coexist. |
HERE_FINGERPRINT_SALT |
HMAC salt for secret fingerprints. Replace at install. |
Websites
A browser is one window to Windows, so without help every site inside it is the same app and a
rule like "never capture my bank" cannot be enforced. That help is a browser extension in
extension/, and a native messaging host, here-collector-browser, that the browser starts.
# 1. chrome://extensions -> Developer mode -> Load unpacked -> pick .\extension
# 2. copy the extension ID Chrome shows, then:
.\scripts\install-extension.ps1 -ExtensionId <that-id>
# to undo:
.\scripts\install-extension.ps1 -Uninstall
Native messaging rather than a local port, because collectors have no network path and a socket would have meant opening one. The extension has no content scripts and no host permissions, so it cannot read a page or see what you type — a limit Chrome enforces rather than one we promise. It reports the host name only, never the address, and nothing at all from a private window; the host program refuses anything that arrives looking like a URL, so an extension bug cannot put a path in the database.
Deleting
Deleting locally is the easy half. The copy that matters is the one that was uploaded, so a
deletion covering anything with an uploaded_at produces a request on the same durable queue as
the uploads — priority -1, so it ships before new records — and the uploader retries it until
the server confirms. Until then the Account tab shows it as waiting, not done.
node scripts\delete-records.mjs <suffix> <event-id>... # specific records
node scripts\delete-records.mjs <suffix> --everything # all of it, every session
node scripts\delete-records.mjs <suffix> --receipts # what went, and whether the server confirmed
Four states, and the difference between them is the point:
| State | Means |
|---|---|
local only |
none of it had been uploaded, so there was nothing on a server to remove |
waiting |
the server has been asked and has not confirmed — retried until it does |
gone from the server |
the server confirmed the records are gone |
still on the server |
the server refused part of it, and those copies still exist |
A record the server never had counts as success: that is the state the person asked for, and anything else leaves the deletion pending forever. A record belonging to another device is refused and named, because a silent skip is indistinguishable from success.
Pairing and the access log
Both are ports, not implementations, because this moves to a platform that already has accounts, sessions and a database. Everything it has to supply is an interface:
| File | It provides |
|---|---|
auth.ts |
Authenticator — turn your credentials into a Principal |
pairing.ts |
PairingStore + TokenMinter — how a device gets a credential |
access-log.ts |
AccessLog — who looked at whose records |
ingest.ts |
IngestSink — where accepted records go |
deletion.ts |
DeletionSink — how records are removed |
live.ts |
LiveBus — the live stream |
The rules stay in the adapter; the storage, identity and transport are the platform's.
apps/admin/src/identity.ts is the development implementation of the first three, and it is
the file that gets deleted when the real one lands.
Pairing. The web app issues a short code against an account; the person types it into the
console; the uploader redeems it, because the console and the gate have no network path and
giving either one would undo the reason they do not. A code is single use, expires in ten
minutes, and a client token never carries the administer scope — so a leaked token cannot
reach another device's records. The account password never touches the machine being recorded.
# development: mint a code the way the web app would
curl -s -X POST http://localhost:3000/v1/pairing/codes -H "content-type: application/json" -d '{"account":"acme","issued_by":"you@example.com"}'
node scripts\pair.mjs <suffix> HERE-XXXX-XXXX # hand it to the uploader
node scripts\pair.mjs <suffix> # state, and the access log
The access log. With nothing expiring, "who has been looking" replaces "when will this be gone" as the question that matters, so the subject can fetch the answer themselves: a device reads its own log with its own token, the subject is taken from the credential and never from the query, and the client mirrors it locally so a person keeps their own copy of the audit trail rather than asking the people it is about.
Reads are logged where they happen — entriesForRead takes the records as an argument, so you
cannot call it without having done the read — and a read that cannot be logged is refused.
An unlogged read is worse than a blocked one, because the promise is that looking leaves a mark.
Encryption at rest
The records are encrypted on disk with AES-256-GCM. The key is 32 random bytes, generated on first open and wrapped with DPAPI to one Windows user on one machine, so a copy of the folder decrypts to nothing anywhere else.
Being exact about this matters more than the cipher, because a vague claim about encryption is worse than none — it invites people to relax about the cases it does not cover.
What is encrypted
events.payload_json |
the content of every record: commands, typed text, window titles, clipboard fingerprints, frame references |
| blob files | the frame pixels, which are the most sensitive bytes on the disk |
What is not
The index: event ids, timestamps, signal ids, app ids, sequence numbers, gate verdicts. Those are what the store is queried by — deletion by app, counts by verdict, the upload queue's ordering — and encrypting them would mean either decrypting the whole table to answer any question or keeping a plaintext shadow copy of the same facts, which protects nothing.
So somebody holding the files can learn that this person used zen.exe at 14:31 and that the
gate suppressed it. They cannot learn what was on the screen or what was typed.
What it defends against: a stolen or resold laptop without disk encryption, a backup or sync
client that took the profile folder, another account on a shared machine, a support engineer
browsing %TEMP%.
What it does not: code running as that person. DPAPI unwraps for the logged-in user, so anything running as them can ask for the same plaintext the gate can. That is true of every at-rest scheme that does not prompt for a passphrase, and prompting is not on the table for something that has to survive an unattended reboot.
Two smaller decisions worth knowing:
- A deletion tombstone stays in the clear.
{}is a public constant meaning "the content is gone". Sealing it would make a deleted record indistinguishable from a live one from outside the process, which removes the one check somebody can make on the file without trusting us. - Records written before this existed are re-encrypted on the next open, and the log says how many. A promise that only held for records made after an upgrade is not a promise anybody can act on.
Terminal commands
Observed as process creation, not by reading shell history and not by injecting a hook into anybody's profile. When a shell starts a program, its command line, working directory and exit code are all readable for a process running as the same user — which is what Process Explorer does, and it needs no elevation.
The manifest already ruled out the alternative: "It never reads shell history files directly as a workaround." A history file holds everything the person has ever typed, including from before they installed this and from sessions where the signal was off, so reading it would mean the grant controlled nothing.
The honest limitation: only commands that start a program are visible. git push is
recorded; cd .., ls in PowerShell, a for loop and a profile function are not, because none
of them spawn a process. The signal's absence claim says so in the console.
A command is reported when it exits, so the record carries the exit code and the duration. One still running after sixty seconds is reported without them — otherwise a dev server started at nine would not appear until it was killed at six, and the timeline would say nothing happened all day.
Each record is attributed to the shell, not to whatever is in the foreground when the command finishes. A long build would otherwise be filed against the browser somebody read while waiting for it, and the scope rules would be evaluated against the wrong app.
Typed text
There is no keystroke buffer anywhere in this design. Accumulating key events is a keylogger: a process holding a growing buffer of characters, which can leak, can be dumped from memory, and sees passwords before anything has a chance to classify them.
So the collector asks the field instead. UI Automation exposes a control's current value, so it samples the focused field and reports at a boundary. Two things count as a boundary:
- Focus leaves the field. The value at that moment is what was committed.
- The field empties while still focused — a message being sent, a form being submitted. Without this rule the most common typed text on a work machine, everything said in Slack, would be invisible.
Four refusals, in the order they apply:
- No grant, no sampling. The grant is re-checked every five seconds and the field watch is dropped the moment it goes away, so a switched-off signal is not reading text and discarding it — it is not reading it.
- A password field is never sampled, and a field that will not say whether it is one is treated as one.
- A document is counted, not copied. Over two thousand characters and the record carries the length alone: "4,200 characters into Visual Studio Code" is true and useful, and copying somebody's whole file into a telemetry record is not what the signal promised.
- The gate redacts what is left, and
charsis the length before redaction — so a record where the text and the count disagree is a record where something was removed, which is visible rather than silent.
The clipboard
A clipboard is where a password lives for thirty seconds, so this signal is built around never carrying its content. What it stores is the format, the length, and a keyed fingerprint.
The key is what makes it safe and useful at the same time:
- a paste chain across three apps shows up as the same fingerprint three times, which is what makes the sequence learnable; and
- nobody holding the uploaded records can test a guess against them. An unkeyed SHA-256 of
hunter2is a lookup table away fromhunter2; an HMAC under a key that never leaves the machine is not.
The key is per machine, DPAPI-wrapped to the Windows user, and kept beside the store rather than inside it — this collector has no handle on the store and must not acquire one.
The fingerprint is content, so a copy made in a denied app is stored as timing only. That matters: the fingerprint is exactly what would let somebody link "this was copied in an app you denied" to "the same thing was pasted in an app you allowed", across the boundary the person drew.
The schema allows a text field with an explicit clipboard-content grant. No such grant exists
in this build, and the collector has no code path that fills it in.
Turning it off
Three ways, and all of them stop everything:
| The pill | TURN OFF on the bar. It asks once — the button changes to SURE? for four seconds — because the pill sits over your work all day and a stray click would cost you the record of what you were doing. |
| A keyboard shortcut | Ctrl+Shift+Alt+X, once. A three-modifier chord is already deliberate, and its armed state would be invisible to anyone whose pill happens to be covered, so this one does not ask. |
| The console | Stop capture on the Capture tab. |
Whichever you use, the same thing happens: the gate closes the session with a reason, replies, and exits; the supervisor sees that clean exit and takes everything else down. Measured on this machine, every process is gone about three seconds after the press.
here-gated: stopping at the request of hotkey; session sess-… closed
here.collector.focus: no gate on here.gate.ingest.v1 after 4s; exiting rather than lingering as an orphan
here-uploaderd: capture has stopped; exiting with the queue intact
Three details are deliberate:
- It is a message, not a kill. Each surface could
taskkillthe process list, and the console still does as a backstop if the gate is wedged. But then stopping would be an unexplained gap in the history, indistinguishable from a crash. Asking the gate means the session record saysstopped_by_hotkey— a person turning this off is doing something they are entitled to do, and the record should say so. - The collectors exit on their own too, four seconds after losing a gate they had. They used to retry forever, which left capture processes running with nothing supervising them; that is precisely what somebody is entitled to be suspicious about. A longer window applies at startup, where a missing gate means "not up yet" rather than "gone".
- Nothing is deleted. Records stay, and anything queued for upload stays queued and goes on the next run. "Off" is not "wipe" — that is what the Account tab is for.
There is also .\scripts\stop.ps1, which kills the process list by name. It is the
development fallback for a stack started by start.ps1, which runs the roles directly with no
supervisor.
The tray icon
Right-click it for the three things people go to a tray for, and double-click to open the app:
Open here capture
Pause for 15 minutes (or Resume, when it is paused)
─────────────────────────
Turn capture off
It owns its own hidden window rather than borrowing the pill's. The pill is WS_EX_NOACTIVATE
so it can never take focus from your work, and a popup menu whose owner cannot take focus does
not dismiss when you click away.
Open raises the existing window if the console is already running rather than starting a second copy. Two copies cannot both have WebView2: the second comes up blank and raises its own "the interface did not load" dialog, so an Open item that started one would be a button whose job is to break the app.
Shortcuts
Every chord is Ctrl+Shift+Alt plus a letter, and the pill's expanded form lists them:
O |
open the app |
M |
move the pill to the next corner |
I |
change size |
H |
hide or show |
P |
pause for 15 minutes |
X |
turn capture off |
The list and the bindings used to live in two files, and drifted: the pill offered X stop while
X called pause. every_shortcut_on_the_list_is_one_the_process_binds reads the line the pill
actually draws and fails if it names a key nothing registers, or misses one that is bound.
Capture cards
The pill narrates. When a record is stored it can raise a card for a few seconds — which signal, which app, what the gate did to it, and why you are being told:
● Screenshots · zen.exe 3 in a row
kept, 2 areas painted out
first capture from this app [quiet this] [seen]
The last line is the part that matters. An indicator that narrates without saying why trains people to dismiss it; one that says "first capture from this app" is saying something they can check, and one that says "showing each capture while this is new to you" is promising to stop.
It does stop. The ladder in crates/here-gate/src/verbose.rs
climbs on cards shown, never on elapsed time — what earns silence is having been seen, not
having waited out a timer while the laptop was shut:
| Rung | After | Shows |
|---|---|---|
| 0 | from install | a card for each capture |
| 1 | 10 cards | the first time each app is recorded, and whenever nothing is stored |
| 2 | 30 cards | one card when the signal starts recording |
| 3 | 60 cards | no cards; the pill's light and counter only |
The count is in indicator_prefs and survives a restart. A ladder that reset on launch would
narrate the first ten captures of every working day, which is the behaviour it exists to avoid.
Two things refuse to be quieted at any rung: a person who pinned the signal, and the first capture from an app this machine has never seen. The second is the surprise the whole subsystem exists to prevent, so it sits ahead of the stage arithmetic rather than inside it, and it is also the only card that is spoken aloud through the UI Automation live region — narrating every capture would make the pill unusable with a screen reader, which is the same failure as hiding it.
One thing overrules even those: quiet on the signal. That is the person saying "stop telling me
about this one", and honouring it is the difference between a control and a suggestion. It costs
them a card, never a record: the capture still lands in the counter, the hover flyout and the
Activity tab.
The card carries quiet this, so the thing interrupting you holds its own off switch. The
inverse is in the console on each signal row — "Show every capture", "Let it quiet down", "No
cards" — because a control that can only be pressed one way is a trap.
Cards do not stack. One shows at a time for 3.6 seconds, at most four wait behind it, and identical captures fold into a count rather than a queue. The band they use is the same one the hover flyout uses, and the flyout wins: a card arriving over the top of a panel somebody opened would replace what they asked to see with what we decided to say.
Checking it without a screenshot. The pill sets WDA_EXCLUDEFROMCAPTURE on Windows and
sharingType = .none on a Mac, so it is a hole in every screen capture — including ours. Two
things make a card verifiable anyway: the indicator logs each one, and the window changes size
by exactly the card's height while one is up.
# what the pill said, after the fact
Select-String 'card ' $env:TEMP\here-manual\logs\here-indicator.log
grep 'card ' ~/Library/Application\ Support/here/store/logs/indicator.log
On a Mac the card is its own window rather than a band inside the pill, so the third way to
check it is the window list: a second window owned by here-indicator, one level below the
pill, appears while a card is up. cargo test -p here-indicator write_the_card_sheet writes the
three verdicts to PNGs, which is the only way to actually look at one.
here-indicator: card focus · SearchHost.exe - timing only, nothing from the window (first time this app has been recorded)
here-indicator: card input.metrics · zen.exe - kept (first capture from this app)
Looking at it
The console is a native window, so "the tests pass" says nothing about whether anything is on screen. These two scripts walk it:
.\scripts\shot.ps1 -Process here-console -Out home.png
.\scripts\click.ps1 -Process here-console -X 85 -Y 160
.\scripts\shot.ps1 -Process here-console -Out activity.png -NoFront
The console also writes console.log next to the store, including a line for each start and one
for the moment the interface actually rendered. If ten seconds pass without that line, the app
says so in a dialog: a window that opens blank cannot report its own failure through its UI.
The pill deliberately does not appear in these captures. It sets
WDA_EXCLUDEFROMCAPTURE, which removes it from every capture API including screenshots — that is
the S1 invariant working. Check the pill through its window rect and its own log.
Verifying the claims yourself
The network-denial claim is the one worth not taking on trust. The supervisor prints the exact command; it is:
Get-NetFirewallRule -DisplayName 'here-telemetry deny egress: here-collector-focus' | Get-NetFirewallApplicationFilter
The per-machine capture checks, which exit non-zero on failure:
foreach ($s in @('s1-wda-capture','s2-visibility','s3-outline','s4-tick-latency','s6-hotkeys','s5-uia-live')) {
cargo run -q --release -p $s
if ($LASTEXITCODE -ne 0) { Write-Host "$s FAILED" -ForegroundColor Red }
}
The cross-stack check, which compares the gate's own counts with the server's:
node scripts\xstack-check.mjs xstack
Other platforms
docs/PORTING.md is the audit. The short version is that one guarantee does not survive the
move, and it changes what the product can promise rather than how it is built:
Wayland is not one thing, and the difference between two compositors is larger than the
difference between Wayland and X11. Capturing a single window works everywhere through
xdg-desktop-portal, and since ScreenCast v4 the grant survives a restart instead of prompting
each session. What varies is the rest:
| wlroots (Sway, Hyprland) | KWin | Mutter (GNOME) | |
|---|---|---|---|
Indicator provably visible (layer-shell) |
yes | yes | no |
Which window is in front (foreign-toplevel) |
yes | yes | no |
Clipboard without focus (data-control) |
yes | yes | no |
On a compositor with layer-shell the indicator guarantee is stronger than the one shipping
on Windows today: a surface on the overlay layer cannot be covered by construction, so there is
nothing to poll and nothing to race. On GNOME none of the three exist, so window activity,
screenshots, typed text and the clipboard are refused there — with the reason — rather than
granted and left to produce nothing.
macOS has a different problem, and it is a permission rather than a protocol. The APIs are all
there — ScreenCaptureKit filters to one window, NSWindow.sharingType excludes our overlay,
occlusion is computable from the window list — but three of them are behind a switch a person
grants per-application and can revoke at any time: Screen Recording, Accessibility and Input
Monitoring. So on a Mac the capability report is not a table of what the platform supports, it
is a preflight of what this machine has actually agreed to, taken at startup and turned into
refusals with the name of the setting to change. here_os::from_macos is that mapping and it is
tested against every combination of the three.
So the port starts with crates/here-os, which states what the client needs from a machine and
what each platform can actually do:
pub struct Capabilities {
pub window_identity: bool,
pub window_titles: bool,
pub indicator_visibility_verifiable: bool,
pub screen_capture: bool,
pub single_window_capture: bool,
pub self_exclusion_from_capture: bool,
pub accessible_fields: bool,
pub global_input_counts: bool,
pub clipboard_readable_unfocused: bool,
pub command_lines_readable: bool,
pub per_process_network_denial: bool,
pub hardware_bound_key: bool,
}
The gate asks it at startup and refuses grants it cannot honour, with the reason attached:
here-gated: every signal is supported on this machine
On GNOME Wayland the same line would name window activity, screenshots, typed text and the clipboard, and those signals would not be granted however they were requested. That is the difference between a limitation somebody reads about and one the software enforces.
A refusal is not the only way a machine can fall short, and the second way is the more
dangerous one. A reduction is a signal that runs and delivers less than the README promises:
on a Mac without Screen Recording, window activity still records which application somebody was
in and for how long, and cannot record which document, because kCGWindowName comes back
absent. Nothing about that looks wrong — a focus record with no title reads exactly like a
record for a window that has no title — so Capabilities::limitations says it out loud,
alongside the setting to change, and the console's summary line carries it:
here-gated: not available here: frames, input.metrics, text.commit; reduced here: focus
Two claims weaken off Windows and the honest place to say so is here: network denial is an enforced firewall rule only on Windows — elsewhere it rests on a reviewed binary with no network code — and the store's key is hardware-bound on Windows and macOS but protected by file permissions alone on Linux.
The client runs on a Mac. Four processes — the gate, the indicator and two collectors — with records flowing over Unix domain sockets and the hold rule enforced. What is in:
| Transport | Unix domain socket in a 0700 directory, the same kernel-enforced access control as the pipe's ACL |
| Where things live | ~/Library/Application Support/here/store, sockets beside it |
| Which window is in front | NSWorkspace.frontmostApplication for the app, CGWindowListCopyWindowInfo for the window |
| Clipboard | NSPasteboard.changeCount, hash-only policy shared with every platform |
| Indicator | an NSWindow at status level, sharingType = .none, drawn from a bitmap |
| Indicator visibility | CGWindowListCopyWindowInfo with OnScreenAboveWindow, no permission needed |
| Capability preflight | Screen Recording, Accessibility and Input Monitoring, asked rather than assumed |
| Input counts | CGEventTap, listen-only, counting keys and never characters |
| Typed text | AXUIElement, sampled at a boundary, never a keystroke buffer |
| Password-field probe | AXSecureTextField as role or subrole, failing closed |
| Screenshots | CGWindowListCreateImage, one window, with the password fields located first |
| Terminal commands | proc_listpids on a timer, KERN_PROCARGS2 for the command line |
| Websites | the same native-messaging host as Windows; only the registration differs |
| Supervisor | all nine roles, and it says out loud that network denial is not enforced here |
| Console | the Tauri app, which now describes the platform it is on rather than Windows |
| Controls | a menu bar item and four global hotkeys via Carbon RegisterEventHotKey, because the pill is click-through and a Mac user looks in the menu bar for a background process |
| Capture cards | their own NSWindow under the pill, with the queue and the 3.6-second timing shared with Windows, and spoken through NSAccessibilityAnnouncementRequested |
| Captured-window outline | an NSWindow traced around the window frames are being taken from, four modes told apart by dash pattern as well as colour |
| Hover flyout | a panel beside the pill showing one signal's last six records, opened by observing the pointer with NSEvent.mouseLocation rather than capturing it |
The one deliberate difference is that the Mac pill never takes a click. That is what stops
it swallowing a click meant for the window underneath, and it is free to do: the visibility
check is Z-order coverage, not hit-testing, on both platforms (ADR 0005), so a click-through
pill is not a cheat. The pointer is polled instead of captured — no event tap, no permission,
no interception. Two things follow. Hover-in waits for the next frame rather than arriving on
the event, which is why the loop drops to the frame clock while the pointer is over the pill.
And the card's two controls (seen, quiet this) live in the menu bar, where a Mac user looks
for a background process anyway, rather than as buttons on the card.
Linux has nothing.
Things that only came out of running it, and are the reason this work does not get done by cross-compiling:
NSPasteboardis not thread-safe and does not fail politely. Two threads reading the general pasteboard at once trap inside-[NSPasteboard _updateTypeCacheIfNeeded]and take the process down —EXC_BREAKPOINT, no panic, no unwind, nothing in a log. Every call into it now goes through one mutex, andmany_threads_may_read_the_pasteboard_at_onceis the test that fails without it.- An application on a Mac is a bundle, not an executable. Three unrelated applications ship
an executable called
Electron, so the name a scope rule matches on comes from the outermost.appin the path — which also makesGoogle Chrome Helpermatch a rule written againstGoogle Chrome. - A permission check cost 22 ms.
CGPreflightScreenCaptureAccessandIOHIDCheckAccesseach cross into another process. The first foreground watcher called them through a constructor that looked free, which would have burned nine percent of a core polling four times a second. Cached for five seconds: 120 µs, and a revoked permission is still noticed while the person is walking back from System Settings. - The Keychain cannot hold this client's keys yet. An item's access list is per-binary and
this client is four processes; a second unsigned binary reading an item the first created
raises a modal dialog and blocks. Measured — the reader hung until it was killed. So
hardware_bound_keyis false on a Mac, the key is a file only its owner can read, and code signing is what changes that. - An invisible window can sit in front of the real one. Claude's own process keeps a 1440×32 window at layer 0, alpha 0, parked entirely off the top of the screen — and the window server lists it ahead of the real one. Taking the first layer-0 window meant window activity named a window with no title and the frames collector captured a blank strip, which the gate rejected as a black frame every two seconds. Found by capturing and looking at the result.
- A password box's role is
AXTextField. Only its subrole saysAXSecureTextField. Checking the role alone is the obvious implementation and would have read the password straight out of a real dialog — caught by running it against one. - A cached accessibility handle goes blind.
AXUIElementCreateSystemWidekept in a struct at process start reported "nothing has focus" for the rest of that process's life, while a process launched a second later read the very same focused field. The collector would have sampled nothing all day and reported that nobody was typing. It is created per query now. - Two bodies of one collector can disagree about its own name. The gate looks a collector's
signal up by module id, so
here.collector.text.commitwhere the manifest sayshere.collector.text-commitis a collector whose grant is never found: it connects, it heartbeats, and the console shows the signal as off while the process runs happily. Both macOS collectors had it. There is a test in each now that reads the other body's source. - A tap the system can switch off is worse than one that fails. macOS disables an event tap
whose callback is too slow, and a disabled tap returns zero for ever — which looks exactly
like somebody who stopped typing. The callback re-enables itself, and the collector asks
is_listening()rather than trusting a quiet zero. pgrepcannot see its own ancestors.is_runningusedpgrep -x, which returns nothing for a process that is an ancestor of thepgrep— so the console would have reported every collector as not running and told the person capture had failed while it was working.psfinds the same process by the same name in the same instant. Matching the executable's file name rather than the command line matters for a second reason:pkill -f here-collector-focuswould kill an editor with that file open, and this is the Stop button.- Drawing a circle for every state quietly halved the encoding.
pausedanddisabledshare a colour and are told apart by shape. Caught by looking at the rendered PNG, which is whatwrite_the_pill_sheetexists for — the pill excludes itself from capture, so a screenshot of the running indicator does not contain it.
What is not built
Named so it is not mistaken for done:
- No code signing. The client is unsigned, so SmartScreen and EDR will flag it.
modules.signature_okrecordsfalserather than pretending otherwise. EV cert procurement has lead time and has not started. - No installer. No WiX package, no service registration, no auto-start.
- The browser extension is unpacked and unsigned. It is not in any store, so it has to be loaded through Developer mode and registered with
.\scripts\install-extension.ps1. The console shows those steps when Websites is switched on and nothing is reporting. - No operator identity. Devices authenticate; people do not.
/v1/pairing/codesstands in for a web app that would have a signed-in person behind it, and the live view logs its reads against a placeholder actor. Theadministerscope exists and is enforced, and nothing issues it yet. - The development server keeps everything in memory. Restarting it un-pairs every device and loses the audit trail, which for an audit trail is disqualifying. That is a property of the test backend, not the design —
IngestSink,PairingStoreandAccessLogare where the real thing goes. - Nothing expires, by decision. Records are kept until deleted: they verify paid work and they train a computer-use model, and both need them long term. There is no retention timer to build, and the manifests, the registry and the console all say so (
-1, "until you delete it") rather than quoting a number nobody enforces.contracts-lintfails if those two places disagree. Deletion is therefore the whole control, which is why it reaches the server copy and leaves a receipt. - The admin store is in-memory.
apps/admin/src/store.tssays so at the top. Postgres and Redis go behind the same two interfaces. - The index is not encrypted, only the content. Timestamps, app ids and gate verdicts sit in the clear so the store can be queried by them; see "Encryption at rest" for what that does and does not protect. Nothing at rest protects against code running as the person, either.
- The shell signal cannot see built-ins.
cd,lsin PowerShell and profile functions start no process, so nothing observes them. The console says so rather than implying full coverage. - All of Linux, and one difference on macOS. Every signal has a macOS body and each has been exercised against a real desktop; the pill, the menu bar, the capture cards, the captured-window outline and the hover flyout are all in. The Mac pill takes no clicks by design, so the card's two controls are in the menu bar rather than on the card — see "The client runs on a Mac". On Linux nothing is written.
here-win's nine modules have also not moved behind thehere-ostraits yet, which is Windows-only work. Seedocs/PORTING.md. - Deployment. The admin app builds; it has not been deployed.