Personal runners: threat model and controls¶
A personal runner is the preloop CLI running as a service on a developer's own
machine. In 0.17.0 it can run flow jobs on the harnesses installed there (for
example GitHub Copilot CLI on the developer's own seat) and, after explicit
opt-in on the host, accept remote sessions started from the Preloop console.
That makes the laptop reachable from Preloop. This page states what that means,
which controls apply, and what wave 1 does not prevent.
The controls are numbered T1 to T12. Pull requests that implement them cite these ids and ship a test per id.
Scope of wave 1 (0.17.0)¶
- Harness inventory published by the runner (#1480).
- Flows routed to a runner by harness and model (#1481).
- Remote sessions for
copilot_clionly, started from the web console (#1482 runner protocol, #1483 HTTP API and console). - Workspaces: authorized directories on the host, or temporary checkouts of a tracker repository with a short-lived clone credential (#1484).
- Install prompt after
preloop loginand the account runner policy (#1479, EE #182).
Runner sharing to teams and users is an enterprise plugin (EE #183, #186) on the extension points described under T2.
Deferred to 0.18.0: system versus user service split (#1488), MDM enrollment and device credential (#1344 track), more session harnesses (#1489, #1490).
Workstation correlation¶
Persistent runners report the same account-salted HMAC-SHA256 workstation
fingerprint as preloop agents discover --report. This self-reported correlation
key can link a runner to discovery evidence on the same machine and account.
It is not verified device identity or attestation. Different account salts
produce different fingerprints. Ephemeral runners omit this key.
Both commands use the platform machine ID, falling back to a random ID saved
in the CLI configuration directory. To use an administrative reference instead,
pass the same --device-ref to preloop runner fg and
preloop agents discover --report. The runner saves that reference locally in
runner.json for service restarts; only its salted hash is reported. Pass a new
reference to replace it. The runner refreshes the account salt every five minutes
and sends the refreshed fingerprint on subsequent heartbeats.
Runner API responses expose the fingerprint only to the registrant and
same-account users with manage_agents. Older clients can still register without
it, and older servers without the discovery salt endpoint remain usable.
Assets and actors¶
| Asset | Where it lives |
|---|---|
| Source code in authorized directories | Host disk |
| Harness login (Copilot OAuth token, GitHub tokens, keychain items) | Host user session, owned by the harness |
| Runner token | ~/.preloop/ on the host, rotatable with preloop runner rotate-token |
| Clone credential for a tracker checkout | Runner process memory, for one clone |
| Session events, approvals, audit | Preloop server |
| Actor | Trust |
|---|---|
| Host user (runner owner) | Trusted for their own machine; decides what is enabled |
| Account owner or admin | May start sessions on members' runners in wave 1 (T2) |
| Other account members | No session access to someone else's runner |
| The model and the harness | Untrusted output; may be steered by prompt injection in code or issues |
| A party holding a stolen Preloop session | Treated as the actor it impersonates; limited by T2 to T12 |
Controls¶
T1. Per-harness opt-in on the host¶
Remote sessions are off for every harness until the host user runs
preloop runner sessions enable <harness> on that machine. The server can
never set sessions_enabled; it only reads it from the inventory. A harness can
also be disabled entirely in ~/.preloop/runner.json
("harnesses": {"<id>": {"enabled": false}}); disabled harnesses are still
reported for compliance but are ignored by routing and sessions.
preloop runner sessions disable <harness> revokes at once. Both changes are
audited as runner.sessions_enabled and runner.sessions_disabled with the
host user as actor. Runner rejects with harness_not_enabled_for_sessions.
Implemented by #1482 (command and runner check) and #1480 (inventory field).
Consent text printed by preloop runner sessions enable copilot_cli, which then
asks Enable? [y/N] (default no):
Allow remote Copilot CLI sessions on this machine?
Your account owner, account admins and you will be able to start GitHub Copilot
CLI sessions here from Preloop, using your Copilot seat and login. Sessions run
as you, only in directories you authorize with `preloop runner dirs add` or in
temporary checkouts. Every tool call goes through Preloop approvals and is
audited. You get a notification when a session starts.
Turn off any time: preloop runner sessions disable copilot_cli
T2. Who may start a session¶
Default rule: the runner owner (registered_by_user_id) and account owners and
admins. Everyone else gets 403 not_runner_owner. The check is the authorizer
action runner:session_start (ACTION_RUNNER_SESSION_START). The admin path is
disclosed to the host user in the T1 consent text and in the T3 notice, which
names the actor. Implemented by #1483.
Sharing (EE #186) widens this only through the authorizer, and only with an
explicit allow that names the granting rule; an authorizer with no opinion
never opens a runner. The session routes pass operation (options,
start, list, turn, stop, heartbeat), harness, workspace_kind
and workspace_id. A caller admitted through a grant sees only the
harnesses and directories it covers, lists and drives only their own
sessions (403 not_session_actor otherwise) and cannot open a dirty work
tree. Flow pinning and the flow editor's harness options ask
runner:flow_use with the same contract, and the lease passes the pinned
runner, harness and directory with runner:accept. A lease routed by an
explicit runner_pool that names a personal runner also asks
runner:flow_use for the flow's author (operation: lease), and harness
routing applies the same check (#2176).
The per-start owner approval (T3) stays unless a registered start consent
waiver returns a reason; the waiver is asked only after authorization and
every scope check, and its use is audited as decision: waived.
Revocation: every runner heartbeat re-checks the live sessions started by
someone other than the owner and asks the runner to kill those the rule no
longer allows (access_revoked). The stop frame goes out with that
heartbeat, so a revoked grant stops the grantee's sessions within one
heartbeat, whichever replica took the revoke.
T3. Host-visible notice¶
When the runner accepts a session_start, it writes a runner log line and
raises a desktop notification as the logged-in user:
Preloop: <actor> started a <harness> session in <label> (Windows toast, macOS
user notification, Linux notify-send when present). A failed notification is
logged and does not block the session. Implemented by #1482.
T4. Workspace containment¶
A session runs either in an authorized directory or in a temporary checkout.
Authorized directories are listed only on the host (preloop runner dirs
add|remove|list); the server sees {id, label, mode, harnesses}, never the
path. Paths are resolved with realpath when loaded and again at use; symlinks
and Windows reparse points cannot escape; comparison is case-insensitive on
Windows; the filesystem root, a whole home directory and UNC admin shares are
refused. Unknown or out-of-scope ids fail with workspace_not_authorized.
Temporary checkouts live under ~/.preloop/host-workspaces/sessions/<id> and
are deleted when the session ends. Implemented by #1484.
T5. Short-lived clone credentials¶
For a tracker checkout the server mints a credential immediately before
session_start: a GitHub App installation token scoped to the one repository
with contents: read (at most 60 minutes), or a fresh Bitbucket Cloud OAuth
access token (about 2 hours). Long-lived secrets are never sent to a laptop:
GitHub PAT trackers fail with checkout_requires_app_or_oauth, Bitbucket
access tokens and app passwords fail with checkout_requires_oauth. The
credential travels only inside the authenticated runner websocket message, is
never stored server-side, never logged, never written to .git/config or any
file, is passed to git through an in-memory askpass channel and is zeroed after
the clone. Minting is audited as runner_session.checkout_credential_minted
(provider, repository, expiry; never the token). Implemented by #1484.
T6. Harness credentials are never touched¶
The runner executes the user's own installed, unmodified harness binary. It
does not open the harness credential store or copy or forward its credentials.
Inventory may query the harness's read-only login-status interface. Copilot's auth.getStatus response is reduced to isAuthenticated; the raw
response and any account or token fields are never logged or published.
Inventory reports only
login_state and login_source; for env it checks that a variable name is
set and never reads the value. The child environment is the existing host-exec
allowlist. Any code that reads a harness credential store is a review blocker.
Implemented by #1480 (inventory) and #1482 (session launch).
T7. Mandatory approvals and deny-tool defaults¶
Remote sessions are a prompt injection target: the model reads code, issues and
tool output it did not write. A remote session starts only when the Preloop
approval hook (preloop agents onboard "Copilot CLI" --approvals) is installed
for the host user; otherwise the runner refuses (copilot_approval_hook_missing
in the existing host-exec code). Every tool call is evaluated by Preloop
policies and can require a console approval. The default deny-tool list from
the host-exec profile applies, deny always wins over allow, and
--allow-all-tools is never used without the hook. Implemented by #1482.
T8. Copilot specifics¶
--no-remoteis always passed, so the session cannot also be steered through GitHub's own remote control.--no-ask-userand non-interactive-pturns; each turn is one process resumed with--resume=<session uuid>.- BYOK variables (for example
COPILOT_PROVIDER_BASE_URL) are stripped by the environment allowlist, so a seat session really uses GitHub-hosted models under the user's Copilot plan and policies. - Model-judged (assisted) permission approval is never enabled: the runner does
not pass
--assisted-approval(present in Copilot CLI 1.0.95 help, marked experimental) and does not switch to the/permissions assistedmode. Tool decisions come only from the Preloop approval hook (T7).
Implemented by #1482.
T9. Limits¶
Defaults: at most 2 concurrent remote sessions per runner
(max_concurrent_reached), idle timeout 30 minutes (idle_timeout), a maximum
session duration (max_duration), one turn in flight per session (409
turn_in_progress), event payloads bounded to 64 KB and redacted by the
existing redaction. Implemented by #1482 and #1483.
T10. Kill switch and stop¶
The account kill switch (see Account kill switch)
applies to remote sessions: halting flows or agents ends live sessions with
killed_by_kill_switch and rejects new starts. The actor can stop a session
from the console (graceful or kill); the host user can stop it locally
(stopped_on_host). A stop mid-turn kills the harness process. Implemented by
1483 (server) and #1482 (runner).¶
T11. Audit¶
Every step is an audit event with resource_type = runner_session and details
that always carry actor, runner id and name, host, harness, model and workspace
kind and label: runner_session.start_requested, .started, .rejected,
.turn_sent (turn id and text length, not the text), .stop_requested,
.ended (with end_reason), .checkout_credential_minted, plus
runner.sessions_enabled, runner.sessions_disabled and
runner.harness_inventory_changed. Implemented by #1483 and #1482.
T12. Revocation and policy¶
Ways to cut access, from narrowest to widest: stop the session (T10); disable
the harness for sessions on the host (T1); remove an authorized directory;
preloop runner disable or stop on the host; rotate the runner token
(preloop runner rotate-token) or delete the runner in the console; account
kill switch. The account runner policy (runner_policy: optional,
required or forbidden) decides whether the CLI asks, informs or stays
silent after login. Implemented by #1479 (login prompt and field) and EE #182
(policy and compliance view).
Consent text shown after preloop login when the policy is optional (#1479):
Install a Preloop runner on this machine?
The runner starts at login, runs your Preloop flows locally and reports which
coding agents are installed here (names, versions, signed-in state; never your
credentials). Remote sessions stay off until you enable them per agent.
Install and start the runner now? [Y/n]
When the policy is required, the same text is printed without the question,
followed by the account message. When it is forbidden, one line says so.
What leaves the machine¶
Sent to Preloop:
- Harness inventory: harness ids, versions, signed-in state, model ids, governance state. Never executable paths, argv, environment values or tokens.
- Authorized directory ids, labels, modes and harness lists. Never paths.
- Session events: agent messages, tool calls and results, usage, stderr, bounded and redacted (T9).
- Approval requests and decisions, turn summaries, audit events.
Not sent to Preloop: repository content as such. The harness itself sends what it needs to its own model provider (GitHub for Copilot) under the user's seat, as it would in a local session; tool results the agent produces can include file excerpts and are visible in the session events.
Laptop sleep and offline¶
- The runner reconnects after sleep or network loss and reports
session_statefor every live session, so delivery resumes. - While the runner is asleep or offline, a started session is
runner_away: a turn sent then is held on the server and delivered on reconnect, and pending approvals stay open with nothing able to act on them. If the runner does not reconnect within the resume window (the session's idle timeout, counted from when it went away), the session ends withrunner_away_timeout, an audit row and a notice to the user who started it. A session the runner never confirmed ends withrunner_offline. Flows that need the harness follow theirharness_fallback(queue,fallback_serverorfail). - Idle sessions end after the idle timeout (T9) whether or not the laptop is awake. A Copilot session file survives on disk, but a new turn requires a new session.
- Temporary checkouts are deleted when the session ends; if the runner was offline, cleanup happens on its next start.
- A harness flow pinned to the runner (
agent_config.runner_id) follows itsoffline_policybeforeharness_fallback:fail(default) fails at once withrunner_asleeporrunner_offline;reroutemoves the run only to another runner the flow's author may use with the same harness, model and billing, never to a gateway-metered path.
What wave 1 does not stop¶
- A user can uninstall or stop the runner, or use a harness on a machine that
never enrolled. Wave 1 detects this, it does not prevent it: the compliance
view (EE #182) shows members as
staleornever_connectedand lists ungoverned harnesses. Prevention belongs to the MDM track (#1181 managed-agent deployment recipes, #1186 managed-config bundles). - An account admin can start a session on a member's runner without asking first. The member has consented (T1) and is notified (T3); per-session approval by the host user is not in wave 1.
- Anything the harness does outside a Preloop session, and what the harness sends to its own model provider.
- A compromised host user account: the runner runs as that user and inherits its access.