Threat model and security profile
This document describes who Debuglet trusts today, where its trust boundaries are, and what the supported profile does and does not promise. To report a vulnerability, see SECURITY.md.
The alpha has exactly one supported profile: a trusted local environment, described below. Every statement here is about the current code. Where a boundary is not enforced, this document says so instead of describing an intention.
Actors
Section titled “Actors”- Submitter — anyone who can reach the dispatcher’s HTTP API, directly or through
pkg/clientanddbl. Submits guest modules, arguments and a resource policy, and reads state and output. - Guest author — whoever wrote the WASI module. Frequently but not necessarily the submitter; a submitter may run a module they did not build, and the dispatcher records no relationship between the two.
- Dispatcher operator — runs
cmd/dispatcher, ownsinternal/dispatcher/config(bind host, ports, TLS, Sui, CORS) and the dispatcher SQLite database, which holds every run record, stored guest output, user rows and payment rows. - Executor owner — runs
cmd/executoron a machine with network access. Ownsinternal/executor/config: the executor ID, the TESLA seed, the packet-counter mode and interface, the public listener host and port ranges, pricing, and the executor SQLite database. Contributes the network position from which measurement traffic leaves. - Destination controller — operates an address a guest sends to. Is not a party to the submission and is not contacted before traffic arrives.
- Outside observer — anyone who sees packets on the path, or who can reach the dispatcher’s HTTP API to read
/executorsand/executors/:id/tesla.
Assets and data flows
Section titled “Assets and data flows” submitter dispatcher host executor host destination ┌──────────┐ HTTP (:9000) ┌────────────────────┐ ┌────────────────────┐ ┌──────────────┐ │ dbl / │ ───────────────► │ transport/api │ │ scheduler (SQLite) │ │ target │ │ pkg/ │ intent, submit, │ (echo, cmux) │ │ debuglet runtime │ │ service │ │ client │ state, logs, │ dispatcher core │ │ (wazero) │ │ │ │ │ cancel │ SQLite database │ │ tagger, ratelimit │ │ │ └──────────┘ └─────────┬──────────┘ └─────────┬──────────┘ └──────▲───────┘ │ │ │ direct gRPC (:9001) │ ExecutorService calls ride the │ guest sockets │ tagged DispatcherService ◄──┼── reverse yamux stream on :9000─┤ (host functions) │ IPv4 calls from executor │ └─────────────────────────┘ │ offline verifier reads /executors/:id/tesla and captured packets| Asset | Where it lives | Who can read it today |
|---|---|---|
| Guest module bytes, arguments, policy | UploadRequest on the control channel; executor debuglets table | Dispatcher operator, executor owner |
| Guest output | Streamed over DebugletStream, stored in the dispatcher debuglet_logs table | Anyone who can call GET /debuglet/:id/logs |
| Run state and metadata (executor, addresses, usage) | Dispatcher debuglets table | Anyone who can call GET /debuglet/:id/state |
| User rows (UUID, name) | Dispatcher users table | Anyone who can call GET /user-ids |
| Payment intent and auth key | Dispatcher transactions table; returned by PUT /payment/intent | Anyone who can create an intent |
| Control session token (32 bytes) | internal/dispatcher/transport/rpc memory, gRPC metadata, never persisted | Dispatcher and the executor it was offered to |
| TESLA seed and undisclosed chain keys | Executor configuration and internal/executor/tagger/tesla memory | Executor owner |
| Disclosed TESLA keys and anchor | internal/dispatcher/tag.KeyStore, in memory only, per executor and chain anchor, at most four chains per executor | Anyone who can call GET /executors/:id/tesla |
| Executor network position | The executor host itself | Executor owner; effectively, any accepted submitter |
What the dispatcher stores in the rows above stays until the operator deletes its state directory; the executor deletes a run’s module bytes, arguments and policy when the run ends, and what is held only in memory ends with the process. Stored state names the directories and states that the product has no retention period, export or deletion route.
Trust boundaries
Section titled “Trust boundaries”B1 — submitter to dispatcher HTTP. internal/dispatcher/transport/api/routes.go installs AuthMiddleware, which verifies a server-issued session and records the caller it establishes. A session is a public selector plus a secret verifier; only the verifier’s SHA-256 digest is stored, and it is compared in constant time against the row the selector names. A credential that is missing, malformed, of another kind, unknown, expired or revoked is rejected, and every protected route answers 401 rather than continuing anonymously. PUT /user issues an account key and a recovery code once, POST /auth/login exchanges the account key for a 12-hour session, POST /auth/logout revokes it and POST /auth/recover replaces both account credentials and revokes every session of that account. A user UUID is no longer a credential: presenting one as a cookie or bearer token authenticates nobody, and GET /user-ids is an operator operation. Every route carries an explicit decision (docs/API.md publishes the matrix): run state, output, cancellation, the run list, the payment status and the by-IP lookup are scoped to the account that owns the object, the destination limit and account enumeration require the operator role, and only the executor list, the TESLA parameters, the contract, the version and account registration are public. A run recorded before ownership existed is reachable by nobody. A browser client that keeps the session in the session_token cookie (HttpOnly, SameSite=Strict) must repeat the session’s CSRF token in X-Debuglet-CSRF on every state-changing request; a bearer token needs no such proof. The cookie’s Secure attribute comes from the dispatcher’s own configuration — it terminates TLS itself, or server.behind_tls_terminator says a terminator stands in front of it — and never from X-Forwarded-Proto or its relatives, which are set by whoever sent the request. With no cors.allowed_origins configured the dispatcher still serves Echo’s default CORS policy, which allows every origin without credentials, so a cross-origin page can read the public routes but cannot send the cookie. What remains unenforced on this boundary: there is no quota, rate limit or ban list, account registration is open to anyone who can reach the port, and no route grants the operator role, so an operator account exists only where the dispatcher host granted one or the local development profile bootstrapped its own.
B1a — the local development profile. A dispatcher serves a request that presents no credential at all as its own local operator, and records its runs without an owner, only when both of the following hold: the operator set local_development = true in the [server] section of the dispatcher configuration, and cmd/dispatcher/main.go recognises the environment that key describes — sui.disabled, tls.disable and both listeners actually bound to loopback, which is the configuration the local role commands generate and no deployment example does. The key alone turns nothing off, and neither does the environment: a deployment that binds loopback behind a proxy, with the TLS listener disabled because the proxy terminates and payments disabled because it is wallet-free, keeps authentication enforced. Setting the key where the environment does not match refuses startup with an error naming the condition, the configuration reader refuses it outright when tls.disable, sui.disabled or a loopback server.bind_host disagree, and a dispatcher that does serve the profile logs a warning naming it at startup. That is the documented bypass the wallet-free local flow depends on. It does not weaken the rest: a credential that is presented but does not verify is still refused, an authenticated request is still scoped to its own account, and GET /me and GET /list-debuglets still need a credential because the bypass names no account. Turning the profile off makes the ownerless runs it recorded unreachable rather than reassigning them.
B2 — executor to dispatcher control plane. An executor dials the dispatcher’s HTTP port and the cmux matcher in cmd/dispatcher/main.go routes non-HTTP bytes to the yamux listener. BidiServer.registerExecutor (internal/dispatcher/transport/rpc/bidi.go) mints a controlsession.Binding plus a 32-byte random token, calls Hello back down that stream, and adopts whatever executor ID the peer returns. Direct DispatcherService calls on the gRPC port are admitted only with matching binding and token metadata (controlrpc.Read and controlrpc.Credentials.Matches, constant-time), so the token fences one incarnation of one session, and RenewLease proves the reverse path with a ProbeSession round trip before extending it. With tls.disable = false the dispatcher terminates TLS on both control transports: the shared HTTP/yamux socket is terminated before the cmux matcher in cmd/dispatcher/main.go, and the direct gRPC listener carries the same identity, loaded once at startup by internal/dispatcher/config. The executor verifies that identity on both of its channels against credentials.ca_cert, or the host’s trust store when none is named, and against tls.server_name or the host it dialled; internal/executor/executor.go skips no verification. With tls.require_client_cert an executor must also present a certificate issued by tls.ca_file: the direct listener refuses the handshake without one, and the reverse stream is refused after protocol matching by VerifiedClientListener, which leaves the HTTP API on the same port reachable by submitters who hold none. Which executor is on the other end is established by enrollment (internal/dispatcher/enrollment): an operator binds an executor ID to the SHA-256 fingerprint of one client certificate with debuglet-dispatcher -config FILE -enroll-executor ID, which prints a single-use token that expires in 24 hours and is stored only as a digest. The executor presents it once in Hello; the dispatcher consumes it and records the fingerprint of the certificate that connection presented. Afterwards the claimed ID is checked against that binding before an owner exists — so a peer holding other material from the authority registers nothing and evicts nothing — and every direct DispatcherService request must arrive over the same certificate, which the session token alone never proved. RenewLease re-reads the binding, so a rotated or revoked node loses its lease at the next renewal, while the fencing, the immediate lease loss and the local drain stay exactly as they were. This holds only where the dispatcher verifies client certificates itself: without tls.require_client_cert there is no credential to bind to, the dispatcher says so at startup, and the ID is a self-assertion again. In the supported profile below tls.disable = true, so none of this is in force and the control plane is cleartext on loopback.
B3 — dispatcher to executor commands. Upload, Abort, Bandwidth and ProbeSession arrive over the reverse stream and are admitted in internal/executor/transport/rpc/server.go by the same credential match plus a lease check; Upload additionally requires the binding carried in the request body to equal the admitted one, and internal/executor/rpc_handlers.go re-checks the binding and, for aborts, the persisted run binding through scheduler.CancelBound. The executor therefore refuses commands from a stale or foreign control session, but it does not evaluate what it is asked to run: any dispatcher holding a live session can upload arbitrary module bytes with an arbitrary address allowlist.
B4 — guest to executor host. Guests are WASI command modules run by wazero (internal/executor/debuglet/debuglet.go). The runtime is the interpreter with WithCloseOnContextDone(true); no filesystem, environment variables or preopened directories are granted, and argv is the submitter’s arguments. The only non-WASI capability is the env host module registered in registerHostFunctions: connect_tcp, connect_tls, connect_udp, connect_icmp4, their send and receive functions, close_tcp and close_icmp4 (there is no close_udp and no TLS-specific close), accept_tcp, get_tcp_addr, get_udp_addr, receive_udp_from, drain_connection, get_remote_addr and the SCION-UDP functions. Every one of those imports that moves traffic is checked against internal/executor/debuglet/netpolicy first: the operator policy from [network.policy] and the run’s declared destinations, which both have to admit the traffic. Outbound calls resolve the destination, check the resolved addresses and the port, and then connect to the address that was checked rather than to the name, and HostDialer.Control re-checks that address in the hook the operating system calls between creating the socket and connecting it, so a refused destination is never contacted. accept_tcp closes a peer the policy refuses and waits for the next one, receive_udp_from drops a datagram from such a peer without delivering it, and connect_icmp4 additionally requires the job to have requested ICMP and the process to hold the raw-socket privilege. The SCION imports are registered but refuse in the supported profile. Nothing else is bounded per guest — the module runs inside the executor process with no memory ceiling beyond the module’s own declared maximum, no CPU accounting and no namespace or seccomp confinement, and its wall-clock bound is the policy timeout applied through the operation context. Host-function failures are raised as Go panics that trap the module.
B5 — executor to destination. Outbound reach is the operator policy and the submitter’s declared destinations together, checked at B4 on every call. The operator half denies this host, private, carrier-grade, link-local, unique-local, multicast and reserved ranges, including addresses that carry an IPv4 address inside an IPv6 one, plus whatever denied_destinations names and every port outside permitted_ports; local_targets re-allows loopback and is off unless an operator writes it, so a deployment that never configures a policy reaches no service behind its own loopback interface; the local, wallet-free environment sets it and the configuration it ships says why. Names are resolved before the check and the job’s declared destinations are resolved again on every call, so an alias reaches only what its addresses reach and a destination that moves out of the declared set stops being reachable. What the policy still does not have is a consent step: denied_destinations is an operator’s list, not an authenticated opt-out by the destination, so a target that did not ask to be measured is protected only if this executor’s operator listed it. Rates are enforced per destination and per run by internal/executor/ratelimit (with packet_counter omitted or "auto" and a configured or discovered interface, an eBPF counter when its objects load and attach; a load the kernel refuses with EPERM, EACCES or EINVAL (an unprivileged process, or a program or map this kernel does not accept) or a failed attach falls back to the in-process fallback token buckets with a logged warning naming the cause, any other load failure stops the executor, and packet_counter = "fallback" or no interface uses those token buckets directly); accepted connections now carry that accounting too, and listener datagrams and SCION packets are accounted through ratelimit.Accountant instead of moving unmetered. When the policy sets listen_tcp, listen_udp or listen_scion and the executor has public_host/public_ports configured, the guest also receives inbound connections; those peers are admitted by the same policy, and a peer outside it is closed or dropped before the guest sees it.
B6 — packet attribution. With an eBPF tagger on a configured interface, outgoing IPv4 packets carry a 16-bit TESLA tag in the IPID field (internal/executor/tagger, internal/executor/tagger/tesla): SipHash-2-4 keyed with the run’s per-measurement key over the first 64 bytes of the packet, its IPID and checksum zeroed. The program attaches through TCX, or as a direct-action tc filter on a clsact qdisc where the kernel has no TCX; the startup log names which. The executor discloses chain keys through Heartbeat; the dispatcher keeps them in memory in internal/dispatcher/tag.KeyStore, per executor and chain anchor, so the chain a restarted executor announces never mixes with the one before it; at most four chains per executor are kept and the oldest is dropped when a fifth appears. GET /executors/:id/tesla publishes the registered anchor and the latest key disclosed on that chain. tools/verify_pcap.py is the reference verifier. The TESLA anchor k_0 is public, so an executor never tags with it: packets sent during the first epoch after start carry no attribution tag, and the first tag is signed with k_1 once the key refresh, which runs every half epoch, has installed it after epoch 1 begins. The chain ends at tesla.chain_length epochs: the last key the executor tags with is the last one it discloses, and once the key refresh reaches the chain’s last epoch nothing more is tagged. An executor whose chain has run out refuses every new upload until it is restarted and logs that on the first heartbeat that observes it; runs it admitted earlier continue with untagged packets. The dispatcher learns of the exhaustion only through the refused uploads. Without an eBPF tagger the pure-Go tagger sends a run’s UDP and ICMP to IPv4 destinations itself, through raw sockets, and writes the same tag; this needs CAP_NET_RAW. TCP and TLS packets, IPv6 packets and a UDP datagram too large to send unfragmented stay untagged, as does every packet when raw sockets are refused; the executor logs this. The keystore is not persisted, so a dispatcher restart loses every key needed to verify buffered packets.
B7 — payment authority. PUT /payment/intent prices a batch from the executor-advertised price_per_bw_s and returns a transaction ID and an auth key. PUT /debuglet accepts a batch when the transaction exists, the auth key matches, the transaction is Paid, and an MD5 over the gob-encoded request equals the stored hash (internal/dispatcher/transport/api/util.go). In the supported profile that is thinner than it looks: CreatePaymentIntent returns an empty auth key for TEST, CreateDummyIntent stores the transaction with no auth key and status Paid immediately, and the comparison in PutDebuglets is satisfied by any empty value. The whole capability is therefore the transaction ID plus a request that reproduces the stored gob/MD5 hash, and Paid is automatic. That capability is per batch and is no identity at all; what admits a submission is ownership of the order. PUT /payment/intent records the authenticated account that asked for it in transaction_users, and PUT /debuglet admits the batch only when the requesting account owns that order, answering another account’s transaction exactly as it answers an unknown one. SubmitDebuglets (internal/dispatcher/debuglet_service.go) records the owning account in debuglet_users, so an accepted run has an owner unless it was submitted through the local development bypass. internal/dispatcher/payments performs no transfer for TEST. Chain methods exist in internal/dispatcher/payments/sui and are rejected with 503 when sui.disabled is set; they are not part of the supported profile and are not validated.
Supported profile: trusted local alpha
Section titled “Supported profile: trusted local alpha”The profile is: Linux amd64; dispatcher, executor and client run under one administrator who trusts every submitter and every guest; listeners bound to loopback; tls.disable = true; sui.disabled = true with TEST bookkeeping; foreground processes started by dbl up, dbl dispatcher up/dbl executor up, or dbl demo.
It promises: a submitted module runs on the selected executor; the guest’s stdout and stderr are stored and readable; terminal state is reported and retained across a restart of the same package with the same state directory; a run is bound to one dispatcher incarnation and control session, so a stale peer cannot resurrect or overwrite it; per-run and per-destination bandwidth limits are applied by the executor; the installed payload matches its manifest hashes. The installed checks in environment checks cover only part of that — package identity and payload hashes, one TEST submission, guest output, terminal state and retention across a restart. Control-session fencing and rate limiting are asserted by package tests under internal/dispatcher, internal/executor and internal/controlsession, not by the installed checks.
It does not promise, and the code does not provide: identification of the human behind an account, as opposed to authentication of a credential that account holds; confidentiality or integrity of the HTTP API or the control plane on an untrusted network; isolation of a guest from the executor process or from the executor host; protection of a destination that did not ask to be measured, beyond the ranges and names the operator denies; durable recovery of interrupted runs; proof that a cancelled run stopped; monetary correctness; a signed or supported release.
Authentication of submitters and authorization of every HTTP operation against an owner are provided, as B1 describes; the local development profile of B1a is the one configuration that deliberately switches them off.
A dispatcher that listens on an address other than loopback keeps the authentication and authorization of B1: every request that is not public still needs a verified session and is still authorized against the account that owns the object. What such a listener lacks without TLS is protection of the channel, so a session or a control token crossing the network in the clear can be read by whoever is on the path; TLS adds that protection and replaces none of the authorization. The listen address does not switch authentication off: the bypass of B1a requires loopback listeners, and the configuration reader refuses server.local_development on any other bind address. Setting tls.disable = false on the dispatcher serves both listeners over TLS from tls.cert_file and tls.key_file, with tls.ca_file and tls.require_client_cert covering executor certificates; material that cannot be loaded, a certificate that has expired, and a requirement without an authority all fail startup instead of downgrading a listener. The alternative remains a TLS terminator in front of a dispatcher whose own listeners are plaintext on a network only the terminator can reach, as deploy/docker does with nginx; environment checks states what that terminator has to do. The executor refuses tls.disable = true for anything but a dispatcher on its own loopback interface, so a remote cleartext control session is a configuration error rather than a silent downgrade.
Adversaries
Section titled “Adversaries”Hostile guest. In scope, partially mitigated. It cannot open files or read the environment, and it cannot dial outside the operator policy and the run’s declared destinations. It can exhaust the executor’s memory and CPU, since neither is bounded per run; it can hold sockets and ports for its whole timeout; it can emit arbitrary payloads to admitted addresses at up to its ceiling rate; and with a listener policy it can serve traffic on the executor’s public ports to any peer the run’s declared destinations and the operator policy admit — a peer outside them completes a TCP handshake with the host and is then closed without reaching the guest, and its datagrams are dropped. Nothing in the executor examines module bytes before running them.
Compromised or hostile executor. Out of scope for the supported profile; the dispatcher trusts what an executor reports. It supplies its own ID, price, TESLA anchor and capacity in Hello and Resources, reports its own run states and exits, and streams whatever it wants as guest output. It holds every uploaded module and every argument. Nothing verifies that traffic was actually sent, that a reported exit happened, or that the disclosed keys correspond to the packets a destination saw.
Impersonated control peer. Partially mitigated. Once a session exists, its 32-byte token fences it: another connection cannot issue calls for that session, and controlrpc.Credentials.RedactError strips the token out of reflected peer errors. With TLS enabled the token is no longer readable on the path, and with tls.require_client_cert only a holder of material from the configured authority reaches either control transport at all. The identity behind that material is decided by enrollment: the ID a peer returns in Hello is refused unless the connection’s certificate is the one enrolled for it, so one enrolled executor cannot register under another’s ID or displace it. What stays open is what a certificate means: a node whose key was copied is that node, revocation takes effect at the next lease renewal rather than at once, and without tls.require_client_cert nothing is enforced and anyone who can reach the yamux listener registers under any ID. On the plaintext local profile the token travels in the clear on loopback.
Abusive but valid account. Partly mitigated. An account reaches its own runs and nothing else: another account’s state, output, cancellation, run list and payment status all answer 404, the same as an identifier that names nothing, and GET /executors/by-ip returns only the caller’s own recent run identifiers out of the at most 20 the executor’s ring retains (internal/dispatcher/executor_registry.go), so it is no longer a source of other people’s run UUIDs. PATCH /destination and GET /user-ids require the operator role. What is not mitigated: anyone who can reach the port can register an account, and there is still no quota, rate limit or ban list on the HTTP API, so an account can consume executor capacity up to the scheduler’s own limits.
Malicious destination request. Partly mitigated. PATCH /destination now requires an operator account, so a submitter can no longer raise or lower the capacity used for admission decisions on any destination string. The destination itself is still not authenticated: nothing verifies that the operator making the request speaks for the destination it names, there is no opt-out list, and a destination controller has no path by which its own request would be recognised.
Outside observer. In the supported profile, traffic never leaves the loopback interface. Off loopback with TLS disabled, an on-path party reads the HTTP API in full, including intents, auth keys and stored output, and reads the control plane including session tokens. With TLS enabled on both listeners and verified by the executor, that party is left with connection timing and volume, and with the tagged probe packets B6 describes, which none of this protects.
Abuse reports and destination opt-out
Section titled “Abuse reports and destination opt-out”The repository publishes no abuse mailbox. A report about traffic from a Debuglet executor, or a request that an address or prefix not be measured, is filed as an issue naming the destination and the time window. Issues are public, so a report carries no packet captures and no private data; a reporter who already has a maintainer contact uses the private channel SECURITY.md describes instead.
The operator of the dispatcher handles a report in two steps:
- Request a zero limit with
PATCH /destinationand the body{"destination": "ADDR", "limit": 0}, sent with an operator account’s session; thedblCLI has no command for it. Submit changes for each destination one at a time and wait for the response before sending another. API.md andapi/openapi.yamldescribe the route’s answers. Check the response before reporting success: 204 means the limit was recorded. If the server answers 409capacity_exhausted, active allocations still hold floors above the requested limit: nothing was changed or pushed. Stop those runs through their owners or wait for them to end, then retry; the refused request has not blocked new admissions. If a 500 answer says the limit was recorded but not delivered to every executor, admission has changed but executor delivery is incomplete. Retry the same limit and investigate the delivery failure; do not report traffic as stopped. - Answer the reporter on the issue or the private channel with the destination, request time and actual outcome, including any refusal or incomplete delivery. Keep the report and the action in the deployment’s own records, because the product records no limit change. A successful limit request alone does not prove that traffic stopped or that the destination opted out.
Limits match destination strings exactly as runs declare them: the dispatcher resolves nothing and matches no prefix, so each address or name needs its own request. A zero limit refuses new runs with a positive floor_bw on that destination, but runs requesting no floor can still be admitted. Active allocations retain their charged floors until released; a future scheduled run that has no allocation yet may fail allocation after the limit is lowered. The dispatcher releases reservations and allocations when exit reports arrive. Limits are held in the dispatcher’s memory and return to the default after a restart, so reapply them afterwards. Where the server pushes updates to executors, concurrent deliveries can arrive out of order; coordinate changes with other operators and do not treat a successful response as a durable opt-out.
An executor operator can also add the destination, or a CIDR range around it, to denied_destinations in that executor’s [network.policy]. The executor reads the list at startup and checks it on subsequent connections, as environment checks describes. Applying the policy requires restarting the executor; editing the file alone does not stop current traffic.
This procedure has not been rehearsed against a controlled destination.
Authorization and enforcement responsibilities
Section titled “Authorization and enforcement responsibilities”| Capability | Surface | Who may exercise it today | What enforces it |
|---|---|---|---|
| Register an account | PUT /user | Anyone reachable. It is the credential issuer | Nothing. The issued credentials are stored hashed and returned once |
| Obtain a session | POST /auth/login | Whoever holds the account key, or anyone at all on a local development dispatcher | Constant-time digest comparison in authenticate |
| Present an identity | bearer token or session_token cookie | Whoever holds an unexpired, unrevoked session token | Selector lookup, constant-time verifier comparison, expiry and revocation checks, and a CSRF token for cookie-authenticated state changes |
| Replace an account’s credentials | POST /auth/recover | Whoever holds that account’s recovery code | Constant-time digest comparison. The code names its own account and no other |
| Price a batch | PUT /payment/intent | Any authenticated account | Executor existence and policy sanity in LockPrice. The order is recorded against the requesting account |
| Submit a batch | PUT /debuglet | The account that created the payment order | Ownership of the order, then the gob/MD5 request hash and scheduler admission. The TEST auth key is still empty, so ownership is what admits the batch |
| Read run state and output | GET /debuglet/:id/state, /logs | The account that owns the run | debuglet_users lookup before anything is read; anyone else gets the same 404 as a nonexistent run |
| List a user’s runs | GET /list-debuglets | Any authenticated account, about its own runs | The query is scoped by account UUID, at every page |
| Cancel a run | DELETE /debuglet | The account that owns the run | Ownership decided before the abort is attempted, then the run must exist under the named executor’s current session |
| Find the executor for an address | GET /executors/by-ip | Any authenticated account | The returned run identifiers are filtered to the caller’s own |
| Change a destination limit | PATCH /destination | An operator account | Role check. It changes admission for every account, so it is not a submitter operation |
| Enumerate accounts | GET /user-ids | An operator account | Role check |
| Register as an executor | yamux stream, Hello | The node whose certificate the ID is enrolled to, or a peer holding its unspent enrollment token | The fingerprint of the verified client certificate against the enrolled binding, checked before any replacement. Without tls.require_client_cert nothing is enforced and the ID is self-asserted |
| Enrol, rotate or revoke an executor identity | -enroll-executor, -revoke-executor | Whoever controls the dispatcher host and its database | No route and no control message reaches it. The token is single-use, expires in 24 hours and is stored hashed |
| Command an executor | Upload, Abort, Bandwidth | The dispatcher holding the live session token | Token match, lease validity, per-run binding |
| Report run progress | DebugletState, DebugletExit, DebugletStream | The executor holding the live session token | Token match, executor-ID match, ownedDebuglet binding check; terminal rows are never overwritten by an ordinary state |
| Reach a destination | Guest host functions | Any guest, within the operator policy and the run’s declared destinations | netpolicy admission after resolution, then HostDialer.Control on every socket |
| Authorize a payout | internal/dispatcher/payments | Dispatcher operator’s Sui keystore | Out of the supported profile; TEST performs no transfer |
Credential issuance, expiry, revocation and recovery have an owner in the code, and output visibility is bound to the account that submitted the run. So does executor enrollment: a node credential is bound on the host, rotated by a new token and deleted by revocation. Destination consent has no representation, and PATCH /destination authenticates an operator rather than the destination it speaks for. No route grants the operator role: it is given to an existing account on the dispatcher host with debuglet-dispatcher -config FILE -grant-operator UUID and taken away again with -revoke-operator UUID, both of which open the configured database through the daemon’s own schema checks and exit. That is the only way to obtain it, apart from the account the local development profile bootstraps. The operator of the dispatcher host remains the enforcement point for everything above, which is why the supported profile still requires that operator to trust every party who can reach the control ports.
What the identifiers do not prove
Section titled “What the identifiers do not prove”- A session token proves that its bearer held the secret the dispatcher issued to one account, and that the session has not expired or been revoked. It does not prove who the human behind that account is, how the token was obtained, or that the machine presenting it is the one it was issued to: a stolen token is the account until it expires or is revoked.
- A user UUID is an identifier and nothing more. It authenticates nobody, in a cookie or anywhere else.
- A transaction auth key authorises one batch that hashes to one stored value. For
TESTit is empty, so the auth key by itself proves nothing; what admits a submission is that the requesting account owns the payment order. The auth key still does not identify who submitted the batch. - A packet tag shows that a party holding the executor’s chain key at that epoch produced the packet, and only when it was tagged: by the eBPF tagger, or for UDP and ICMP by the pure-Go tagger. It does not prove which run, submitter or module was responsible, it is 16 bits wide, and the disclosed keys it needs are held only in dispatcher memory. It is not non-repudiation.
- A live control lease means the reverse path answered a probe within the lease window, and that the node credential its session was enrolled with was still bound at that renewal. It says nothing about whether a run is executing or whether traffic left the host.
- An enrolled executor ID means one certificate was presented together with an operator’s token, and that the peer holds the key for it. It does not prove which machine holds that key, that the key was not copied, or anything about what that executor does with the work it accepts.
- Local cleanup means the executor closed the runtime, sockets and limiter entries it owned, and that its bounded joins returned. It is not proof that a remote peer stopped, that a destination stopped receiving traffic, or that stored state elsewhere is consistent.
- A cancellation acknowledgement records that the abort reached the executor’s scheduler for a run bound to that session. It does not certify termination;
internal/executor/operation.goexplicitly allows a runtime that ignores its deadline to outlive the bounded wait. - A restored database row means the record survived; the executor restores with an eligibility function that rejects every prior binding (
internal/executor/node.go), so previous runs are quarantined and counted, never resumed.
Release checklist for untrusted use
Section titled “Release checklist for untrusted use”Extending the profile beyond a trusted local environment — untrusted submitters, or executors operated by someone other than the dispatcher operator — requires every condition below. Each is a condition on the code, not a schedule, and each says whether it holds today and what is still open on it.
- Authenticated submitter sessions instead of an identifying cookie, with issuance, expiry, rotation and revocation, and no credential material published by
GET /user-ids. Holds: sessions are server-issued, stored hashed, expire after 12 hours, and are revoked by logout and by recovery; a user UUID authenticates nobody andGET /user-idsis an operator operation. Still open on this point: nothing rate-limits account registration or login attempts, and registration is open to anyone who can reach the port. - An ownership or role check on every state-changing and every read route, including run state, stored output and cancellation. Holds:
docs/API.mdpublishes the matrix, and the tests ininternal/dispatcher/transport/apidrive every registered route anonymously, as the owner, as another account and as an operator. Still open on this point: the operator role is granted only on the dispatcher host, so an operator of a remote deployment needs shell access to that host. - Executor identities enrolled out of band and bound to both control transports, so an unknown or replayed identity cannot register or displace a live one. Holds: an ID is bound to one client certificate by a single-use token issued on the dispatcher host, checked on
Hellobefore any replacement and on every direct request, and re-read on lease renewal so a rotation or a revocation takes effect. Still open on this point: it is enforced only withtls.require_client_cert, and a terminator standing in front has to hold that requirement itself. - The HTTP API and both control transports over authenticated, encrypted transport, with the cleartext path available only as an explicit local-development choice. The transport half holds: both listeners terminate verified TLS unless
tls.disableis set, the executor verifies the dispatcher on both channels, and cleartext is refused by the executor off its own loopback interface. The authentication half is item 1 for submitters and item 3 for executors. Still open on this point:tls.disable = trueis what the local role commands generate, so a deployment beyond loopback has to configure the transport rather than inherit it. - An explicit memory ceiling and CPU accounting per guest, and a control-plane reserve a hostile guest cannot consume. Open: a guest runs under the wazero interpreter with no ceiling beyond the module’s own declared maximum, and its only bound is the policy timeout.
- The traffic policy enforced across every guest transport, including inbound peers on public listeners, with an authenticated destination opt-out rather than a configured one. The enforcement half holds, as B5 describes. Open: the denial list is still an operator’s list, so it is the only thing keeping an unwilling target out of reach.
- Destination consent and opt-out represented in the product, with
PATCH /destinationauthenticating the destination it speaks for. Open: the route authenticates an operator account and nothing speaks for a destination. - A specified, versioned packet-tag algorithm whose verification limits and documented strength match the tag, with disclosed keys surviving a dispatcher restart. Open: the tag is 16 bits in the IPID field and the keystore is memory only.
- Payment authority scoped and audited, or chain payments kept disabled and the product saying so. Holds through the second branch: chain methods are rejected with 503 where
sui.disabledis set, and this document and README.md both say they are outside the supported profile and unvalidated. Still open on this point: scoping and auditing a live payment authority, which a profile with chain payments enabled would need. - This document, README.md and SECURITY.md updated in the same change, so no profile is advertised before the conditions above hold.