Repository navigation
GraphQL taskEvents subscription streams agent-task metadata for tasks the #268 read gate withholds #329
Description
#268 gated the agent-task query surfaces (REST GET /api/v1/tasks, GET /api/v1/tasks/{id}, and the GraphQL tasks/task resolvers) behind task_visible. The same task data is also emitted through a third surface that fix did not cover: the GraphQL taskEvents subscription over /graphql/ws. An anonymous websocket subscriber still observes the task id, its status transitions, the DID of the actor, and the timestamp, for tasks the gated read path correctly withholds from that same caller.
This is the same defect as #112/#114 one surface over. There, the refUpdates query and REST feeds were gated and the broadcast fan-out emitter was missed; the fix was write-side, putting the ref_update_tx.send behind the announce boolean the push handler already computed. task_event_tx never got an equivalent.
Where
crates/gitlawb-node/src/graphql/subscription.rs:49,async fn task_events. The resolver never readsAuthenticatedDidand applies no visibility filter; it relays whatever enters the channel, filtered only by an optional client-suppliedtask_id.crates/gitlawb-node/src/server.rs:67./graphql/wsis registered after theoptional_signaturelayer, and the layer covers only routes added before it, so the subscription carries no caller identity at all and cannot be gated read-side.- Six unconditional sends into the channel, every one of them after a successful status transition, with no visibility check:
crates/gitlawb-node/src/api/tasks.rs:382(claim),:436(complete),:490(fail), and the GraphQL twins atcrates/gitlawb-node/src/graphql/mutation.rs:80,:122,:164.
The sibling ref_updates resolver directly above documents this exact contract: its safety rests entirely on the write side, because the resolver has no caller to gate against. task_events inherited the shape without inheriting the gate.
Impact
Anyone who can open a websocket to the node observes the lifecycle of every agent task on it, including tasks against private repos and repo-less tasks belonging to other parties. That discloses the task's existence, its id, its status transitions, and the DID of whoever claimed, completed, or failed it. payload and ucan_token are not on this channel, so the credential leak #268 found is genuinely closed; what remains is task existence, activity, and actor identity, for tasks the read path answers with a 404.
Worth noting because it widens who can reach it: db.claim_task (crates/gitlawb-node/src/db/mod.rs) matches on id and status='pending' with no assignee condition, so any signed caller can claim any pending task and thereby generate the event themselves rather than waiting for one. (#275 is open against that handler.)
Verified by execution
Against fix/task-read-auth-gate at c4a36e56 (the head of #327, which carries the current gate), a throwaway test in visible_tasks_tests that drives both halves: seed a repo-less task t1 delegated by another party, confirm the gated query surface withholds it from an anonymous caller, then have an anonymous subscriber on the real schema watch while a stranger claims it through the real handler.
The control half passes: anonymous GET /api/v1/tasks/t1 returns 404 and anonymous GET /api/v1/tasks returns count: 0.
The anonymous subscriber then receives:
{"taskEvents":{"taskId":"t1","oldStatus":"pending","newStatus":"claimed","byDid":"did:key:z6MkStranger","at":"2026-08-13T04:53:18.672482288+00:00"}}Same task, same anonymous caller, one surface withholds it and the other streams it.
Suggested remediation
Gate the emitter, not the reader, for the reason the ref_updates comment already gives: an unauthenticated fan-out channel has no per-subscriber identity to gate against, so the only place to make it safe is what enters the channel.
There is no announce-equivalent boolean for tasks today, but the analogue is available: task_visible(&task, None, &repos_by_id, &rules_by_repo) (crates/gitlawb-node/src/api/tasks.rs:137) is exactly "would an anonymous caller be allowed to read this task", the same question announce answers for a push. The two lookups it needs are the ones collect_visible_tasks already uses, Db::list_repos_deduped_by_ids and Db::list_visibility_rules_for_repos, and every one of the six send sites has database access in scope. Under that gate a repo-less task never broadcasts (its delegator and assignee already hold it and can read it through the gated surface) and a repo-scoped task broadcasts only while its repo is anonymously readable.
Whatever shape it takes, the invariant is that the subscription is only as safe as the set of senders, so it is worth pinning with a test that subscribes anonymously and asserts a withheld task's transition never arrives, rather than only asserting the sender behaves at one call site.
Activity
euxaristia commentedon Aug 22, 2026
Cross-referencing a second, smaller problem on the same route, found while closing a rate-limit gap on #327.
/graphql/wsalso carries no per-IP brake, and it servesQueryRootas well asSubscriptionRoot. A client can open one socket and send{ tasks { items { id } } }repeatedly, reachingcollect_visible_tasksandget_visible_taskwith nothing debiting a bucket.That is a cost problem, not a disclosure one, and it is strictly the lesser half of what this issue is about: over the query root the caller is always anonymous (for the reason recorded above,
/graphql/wsis registered after theoptional_signaturelayer), so it sees only what an anonymous caller may see. The gate holds; what it does not do is stop a prober making the node run the gate's task lookup plus deduped-repo and visibility-rule queries on every message, for free.For context on why this route is now the odd one out, #327 put that brake on the other two surfaces:
4e5adbe—rate_limit_by_ipontask_read_routescoversGET /api/v1/tasksandGET /api/v1/tasks/{id}4e5adbe—rate_limit::TaskReadBrakerides as GraphQL request data and is debited by thetasksandtaskresolvers, coveringPOST /graphql. It is carried as data rather than a router layer because/graphqlis one endpoint for every operation, so a layer would charge unrelated queries and every mutation against the task-read bucket.The resolver-level debit means a fix here needs no resolver changes at all: the brake just has to reach the ws execution context. That is replacing
route_service("/graphql/ws", GraphQLSubscription::new(schema))incrates/gitlawb-node/src/server.rswith aWebSocketUpgradehandler that resolves the client key at upgrade time through the existingclient_key/PeerAddr/push_limiter_trustpath and passes the brake in viaGraphQLWebSocket::with_data.axum'swsfeature is already enabled, so no new dependency for the fix itself. Testing it does need a websocket client, which is not currently incrates/gitlawb-node/Cargo.tomldev-dependencies.Noting it here rather than opening a separate issue because it is the same route and the write-side emitter gate proposed above will put someone in this code anyway. It is independent of that gate, though: gating what enters
task_event_txfixes the disclosure and leaves the query-cost lane exactly as it is.