From 0100e8cbb0d0068e7535abe518fb7778462c057c Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Sat, 27 Jun 2026 23:32:43 +0300 Subject: [PATCH] [beads] import unaddressed findings --- .agents/skills/beads/SKILL.md | 80 +++++++++++++++++++++++++ .agents/skills/beads/agents/openai.yaml | 4 ++ .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 31 ++++++++++ AGENTS.md | 22 +++++++ src/backfill/worker/process.rs | 5 ++ 6 files changed, 143 insertions(+) create mode 100644 .agents/skills/beads/SKILL.md create mode 100644 .agents/skills/beads/agents/openai.yaml create mode 100644 .beads/issues.jsonl diff --git a/.agents/skills/beads/SKILL.md b/.agents/skills/beads/SKILL.md new file mode 100644 index 0000000..a5a3344 --- /dev/null +++ b/.agents/skills/beads/SKILL.md @@ -0,0 +1,80 @@ +--- +name: beads +description: Use when working in a repository that uses bd or Beads for durable project task tracking, issue dependencies, blocker management, multi-session handoff, or shared work memory. Trigger when the user asks to find ready work, claim or close tasks, create follow-up work, inspect blockers, recover project context, or choose between local planning and persistent project tracking. +--- + +# Beads + +Use Beads as the shared project task system. Local plans, scratch files, and personal memories are useful, but they are not the durable source of truth for project work. + +## First Step + +Run: + +```bash +bd prime +``` + +If that prints nothing, check whether the repository has an active Beads workspace: + +```bash +bd where +``` + +## Preferred Route + +Use the `bd` CLI when shell access is available. It is the most compact and direct Beads interface. + +## Core CLI Workflow + +1. Find work: + +```bash +bd ready +bd list --status=open +bd list --status=in_progress +``` + +2. Inspect before editing: + +```bash +bd show +``` + +3. Claim work atomically: + +```bash +bd update --claim +``` + +4. Create durable follow-up work when implementation reveals new tasks: + +```bash +bd create "Short title" --description="Why this exists and what needs to be done" --type=task --priority=2 +``` + +5. Close completed work: + +```bash +bd close --reason="Completed" +``` + +## What Belongs In Beads + +Use Beads for: + +- shared project tasks +- blockers and dependencies +- discovered follow-up work +- work that must survive thread reset, compaction, or handoff +- status that another person or agent should be able to resume + +Use agent-local planning tools only for the current turn's execution checklist. Do not treat them as shared project state. + +## Rules + +- Do not create markdown TODO files as the source of truth when Beads is available. +- Do not use `bd edit`; it opens an interactive editor. Use `bd update` flags instead. +- Prefer `--json` when parsing `bd` output programmatically. +- If hooks are installed, `bd prime` may already be injected. Run it manually when context is missing. +- Do not auto-close or mutate tasks unless the work is actually complete. diff --git a/.agents/skills/beads/agents/openai.yaml b/.agents/skills/beads/agents/openai.yaml new file mode 100644 index 0000000..09c3b8f --- /dev/null +++ b/.agents/skills/beads/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Beads" + short_description: "Project task tracking with bd" + default_prompt: "Use $beads to inspect ready work and manage durable project tasks." diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..c4d8f70 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1 @@ +{"id":"int-9e82e018","kind":"field_change","created_at":"2026-06-27T20:31:50.656833364Z","actor":"dawn","issue_id":"hydrant-7ra","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"test"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..3d93396 --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,31 @@ +{"_type":"issue","id":"hydrant-ct8","title":"Default IPv6 bind exposes management API","description":"Finding: Default IPv6 bind exposes management API\nSeverity: HIGH\nOriginal Finding ID: 35ca6b23efd88191b6b48f02dafd0cd9\nIntroducing Commit: 82e340fdce9249e45f46d34150b8c0acea1c62c7\nRelevant Paths: src/main.rs | src/api/mod.rs | docs/getting-started.md\n\nDescription:\nHydrant's main API router contains both public and unauthenticated management endpoints. Before this commit, the binary read HYDRANT_API_PORT and bound only 0.0.0.0:\u003cport\u003e. The new default constructs two wildcard listeners: 0.0.0.0:3000 and [::]:3000. On IPv6-enabled hosts, this opens the full API surface on all IPv6 interfaces by default. Deployments that protected the old IPv4 listener with an IPv4 firewall, NAT rule, or reverse proxy can unintentionally expose the management API on a globally routed IPv6 address. Because those management endpoints include operations such as changing firehose sources, repository tracking, ingestion state, and DB maintenance, a remote attacker reaching the IPv6 listener can alter service behavior or cause denial of service without authentication. The change also ignores the old HYDRANT_API_PORT variable, increasing the chance that upgraded deployments unexpectedly fall back to the new wildcard default.","status":"open","priority":1,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:31:58Z","created_by":"dawn","updated_at":"2026-06-27T20:31:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-jsk","title":"Unvalidated crawler sources enable SSRF and crawler DoS","description":"Finding: Unvalidated crawler sources enable SSRF and crawler DoS\nSeverity: MEDIUM\nOriginal Finding ID: 9f4f41a0383c8191a7bbf595fe317283\nIntroducing Commit: a3658e247c0692bcd7d38fe35f02efe92de56032\nRelevant Paths: src/api/mod.rs | src/api/crawler.rs | src/control.rs | src/crawler/mod.rs | src/crawler/relay.rs\n\nDescription:\nThe commit adds POST /crawler/sources to the main API router. The handler deserializes a url::Url and mode, but does not restrict the scheme, host, IP range, source count, or validate reachability before calling CrawlerHandle::add_source. add_source writes the URL to the database and spawns a producer task. The crawler base_url helper only rewrites ws/wss to http/https and otherwise leaves arbitrary schemes and hosts unchanged. In relay mode, request failures from the crawler source are logged and the loop immediately continues, so a source such as file:///x or an unreachable localhost/internal address can cause a tight error loop; an http(s) URL can also make the service repeatedly contact attacker-selected internal hosts with fixed XRPC paths. This is especially risky if management routes are reachable outside a trusted admin network; even though management APIs are intended to be private, this commit introduces a new persistent outbound-request primitive and amplifies a pre-existing no-backoff crawler error path.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:06Z","created_by":"dawn","updated_at":"2026-06-27T20:32:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-r6q","title":"Unthrottled filtered Jetstream replay can exhaust DB I/O","description":"Finding: Unthrottled filtered Jetstream replay can exhaust DB I/O\nSeverity: MEDIUM\nOriginal Finding ID: b20c3936a0d88191bf76a5a88121a67a\nIntroducing Commit: e36574744e2bd5df2c602de4450ae0c4c101c517\nRelevant Paths: src/api/mod.rs | src/api/jetstream.rs | src/control/jetstream.rs | src/config.rs | src/control/stream.rs\n\nDescription:\nThe public `/subscribe` Jetstream endpoint accepts attacker-controlled cursors and filters. When a cursor is supplied, `jetstream_stream_thread` replays historical Jetstream events up to the current head. In the replay loop, the new pacing logic calls `replay_chunk_size_for()` and only blocks when the outbound mpsc channel is full. However, filters such as `wantedEventTypes=live` reject all historical replay events in `JetstreamSubscriberOptions::wants()`. In that case `jetstream_event_to_bytes()` returns `None`, no messages are sent, the outbound channel stays empty, and the channel-capacity backpressure never activates. Because this commit also changed the default `stream_replay_chunk_pause` from 2ms to zero, the replay loop immediately reads chunk after chunk from the DB until the replay window is exhausted. A remote unauthenticated client can open `/subscribe?cursor=0\u0026wantedEventTypes=live` and force a full-speed historical DB scan while receiving little or no data; multiple such connections can cause substantial disk I/O and CPU exhaustion. This should be throttled independently of output-channel backpressure, or historical replay should be skipped when filters can never match historical events.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:05Z","created_by":"dawn","updated_at":"2026-06-27T20:32:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-3h9","title":"Unbounded listHosts seeding can exhaust relay resources","description":"Finding: Unbounded listHosts seeding can exhaust relay resources\nSeverity: MEDIUM\nOriginal Finding ID: 398f22beaf9081918de6e7c4f0d5dce5\nIntroducing Commit: 3ccb5332094f4b7e8215d6054e4532352b9be3a0\nRelevant Paths: src/config.rs | src/control/mod.rs | src/control/seed.rs | src/control/firehose.rs\n\nDescription:\nThis commit adds startup seeding from configured relay listHosts endpoints, enabled by default in relay builds. The seeding code fetches each listHosts page with resp.bytes(), which buffers the entire HTTP body without an explicit size limit, then consumes pagination until the remote endpoint stops returning a cursor. For every non-banned host in every response it constructs a WSS URL and calls firehose.add_source(), which persists the source and spawns a firehose ingestor task. A malicious or compromised configured seed endpoint, or poisoned listHosts data from an upstream relay, can therefore return oversized responses, endless cursors, or very large numbers of unique hostnames to cause memory/disk growth, unbounded persistent source creation, many Tokio tasks, and continuous outbound reconnect attempts. Host strings are also accepted directly as URL authority material without policy checks such as private-address filtering, but the clearest security impact is availability/resource exhaustion.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:04Z","created_by":"dawn","updated_at":"2026-06-27T20:32:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-byv","title":"Unbounded describeRepo collection scan can OOM crawler","description":"Finding: Unbounded describeRepo collection scan can OOM crawler\nSeverity: MEDIUM\nOriginal Finding ID: 8804ed4fc0f481918a556492b330e29a\nIntroducing Commit: e263dacf6fb34fea58347054a8b3b22e22f10618\nRelevant Paths: src/crawler/mod.rs\n\nDescription:\nIn filtered crawler mode, unknown DIDs from the relay are passed to check_repo_signals. This commit changed the signal probe from per-signal com.atproto.repo.listRecords requests with limit=1 to a single com.atproto.repo.describeRepo request. describeRepo returns the repository's collection list, which is attacker-influenced and has no limit parameter. The code then calls resp.bytes() and serde_json::from_slice, buffering and deserializing the entire response before checking whether any configured signal is present. A malicious account/PDS discovered through normal crawling can return, or cause an honest PDS to return, an extremely large collections array, causing excessive memory/CPU use and potentially crashing the Hydrant process. This is distinct from the previous bounded query behavior because non-signal collections now have to be downloaded and parsed before the repo can be rejected.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:04Z","created_by":"dawn","updated_at":"2026-06-27T20:32:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-28z","title":"Unbounded crawler PDS ban map enables memory DoS","description":"Finding: Unbounded crawler PDS ban map enables memory DoS\nSeverity: MEDIUM\nOriginal Finding ID: c2eefaff8ac08191afe7275e94231542\nIntroducing Commit: 106ab40683271705bf2096914ebe83075a01040f\nRelevant Paths: src/crawler/ban.rs | src/crawler/mod.rs | src/resolver.rs\n\nDescription:\nThis commit introduces BanTracker, a process-lifetime HashMap keyed by the full resolved PDS Url. check_repo_signals resolves each unknown DID to a PDS endpoint, then unconditionally calls tracker.get_handle(\u0026pds_url), which inserts a new State if that URL has not been seen before. Entries are never removed when a request succeeds, when a ban expires, or when the related DID is no longer relevant. Because crawler listRepos/DID documents are untrusted inputs in the threat model, an attacker controlling many DIDs or a malicious configured relay can provide many distinct PDS serviceEndpoint URLs and force unbounded in-memory state growth. Keying on the whole Url also means variants of the same origin, if accepted by DID document parsing, can create separate ban states while actual XRPC requests are joined to a fixed /xrpc path.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:03Z","created_by":"dawn","updated_at":"2026-06-27T20:32:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-7o8","title":"Unbounded collection enumeration in public describeRepo","description":"Finding: Unbounded collection enumeration in public describeRepo\nSeverity: MEDIUM\nOriginal Finding ID: b4a352096900819184744d6128f0583c\nIntroducing Commit: 6624ccf71b0e47458bbb555539d78c61f866eb02\nRelevant Paths: src/api/mod.rs | src/api/xrpc/mod.rs | src/api/xrpc/describe_repo.rs | src/control/repos.rs\n\nDescription:\nThe commit adds /xrpc/systems.gaze.hydrant.describeRepo to the public XRPC router. Handling a request resolves the identifier, obtains a repo handle, then concurrently calls repo.collections(). The collections() helper scans the entire counts keyspace prefix for that DID and inserts every matching collection into a HashMap. The handler then converts that into a HashSet for the JSON response, discarding the count values. There is no cap on scanned entries, response size, execution time, or memory use. Because collection-count entries are derived from indexed repository data, a malicious indexed account can create many distinct collection NSIDs and then any unauthenticated client can repeatedly call this public endpoint to force large DB prefix scans, allocations, and JSON serialization. This is a resource-exhaustion issue introduced by the new public endpoint.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:03Z","created_by":"dawn","updated_at":"2026-06-27T20:32:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-0hs","title":"Stream event format change can trigger replay DoS","description":"Finding: Stream event format change can trigger replay DoS\nSeverity: MEDIUM\nOriginal Finding ID: 809b9f6d9b2c8191ad81c8da842aee68\nIntroducing Commit: a022ce698b1f3ddad6f88f35a4042f16063c3a33\nRelevant Paths: src/types.rs | src/api/stream.rs | src/api/mod.rs | src/ops.rs | src/backfill/mod.rs\n\nDescription:\n`StoredEvent` is stored in the database with `rmp_serde::to_vec` and replayed from `/stream` with `rmp_serde::from_slice`. `rmp-serde`'s default struct encoding is positional, so adding `live` at the start changes the on-disk field order. Older rows were encoded as `[did, rev, collection, rkey, action, cid]`; the new decoder expects `[live, did, rev, collection, rkey, action, cid]`. The `#[serde(default)]` on `live` does not protect this case because the old first element is present and is decoded as a boolean, which fails. In the stream handler, the cursor is advanced before deserialization, and deserialization failures are logged and skipped. Because `/stream` is publicly exposed, an attacker can connect with an old cursor such as `cursor=0` on an upgraded instance with many old-format events and repeatedly make the server scan the events keyspace and emit one error per legacy event, bypassing normal send-side backpressure for those skipped events. This also breaks historical replay integrity for legitimate clients.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:02Z","created_by":"dawn","updated_at":"2026-06-27T20:32:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-8b8","title":"Public getRecord misses can cause disk-heavy DB reads","description":"Finding: Public getRecord misses can cause disk-heavy DB reads\nSeverity: MEDIUM\nOriginal Finding ID: 5a0782626f448191a4d162e1f6ca8438\nIntroducing Commit: a0a9359a895131783a843a7dd5327f2b8b7da0f5\nRelevant Paths: src/db/mod.rs | src/api/xrpc.rs\n\nDescription:\nThe records keyspace was changed from default Fjall options to `expect_point_read_hits(true)`. The added comment explicitly treats this as turning off Bloom filters because getRecord is assumed to be uncommon. However, `com.atproto.repo.getRecord` is publicly routed, accepts attacker-controlled repo/collection/rkey inputs, and directly performs a point lookup in `db.records` without first checking whether the repo or record exists. An unauthenticated client can therefore generate large numbers of guaranteed-missing record keys. On a large LSM keyspace, disabling miss filters makes those misses significantly more expensive because the database must do point-lookup work across on-disk state instead of cheaply rejecting absent keys. This can amplify public HTTP traffic into disk I/O and blocking-thread exhaustion.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:01Z","created_by":"dawn","updated_at":"2026-06-27T20:32:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-5ci","title":"Legacy DID excludes ignored after key-format change","description":"Finding: Legacy DID excludes ignored after key-format change\nSeverity: MEDIUM\nOriginal Finding ID: 8a58834a31e88191a776fe5ff8ae9c72\nIntroducing Commit: 53ac9dddc4e289bb6eaf5c9fabbf105306e328d1\nRelevant Paths: src/db/filter.rs | src/ingest/firehose.rs | src/crawler/mod.rs\n\nDescription:\nBefore this commit, exclude entries were stored as `x|{raw_did}`. The new `exclude_key` stores excludes as `x|{TrimmedDid}`. Existing databases are not migrated, and the firehose and crawler now only check the new key format. As a result, any DID that was excluded before the upgrade will no longer be skipped, allowing that repo's firehose events to be processed and crawler-discovered repos to be queued despite the operator's denylist. Patch removal also computes only the new key, so legacy entries are not removed unless the operator replaces the entire exclude set.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:00Z","created_by":"dawn","updated_at":"2026-06-27T20:32:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-m6v","title":"Crawler retry wake can busy-loop on jittered retries","description":"Finding: Crawler retry wake can busy-loop on jittered retries\nSeverity: MEDIUM\nOriginal Finding ID: a84c29ca490881918a976bc3b0231ae7\nIntroducing Commit: e558361eb571e7e019a4a7967bb4ae7e666f3f25\nRelevant Paths: src/crawler/mod.rs\n\nDescription:\nprocess_retry_queue() adds a randomized backoff to each retry entry and correctly treats the entry as not ready when state.after + backoff is still in the future. However, the newly introduced Duration-based wake calculation uses only state.after - now. If now is after state.after but before state.after + backoff, to_std() fails and unwrap_or(Duration::ZERO) is used. The retry thread then calls sleep(Duration::ZERO), immediately re-enters process_retry_queue(), scans the retry keyspace again, and repeats until at least the minimum jitter window has elapsed. A malicious PDS or relay-listed DID that causes retry entries, especially with large Retry-After/throttle durations, can make the crawler retry thread consume CPU and repeatedly hit the DB for a predictable window. The wake duration should be calculated from the actual readiness threshold, e.g. state.after + backoff - now.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:00Z","created_by":"dawn","updated_at":"2026-06-27T20:32:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-6w8","title":"Crawler pause does not stop retry queue processing","description":"Finding: Crawler pause does not stop retry queue processing\nSeverity: MEDIUM\nOriginal Finding ID: 1db4d5a08c8c81918e69516f00833326\nIntroducing Commit: ec1b1027784eb565a4310e955e2688b100408247\nRelevant Paths: src/main.rs | src/crawler/mod.rs\n\nDescription:\nThis commit changes startup so a Crawler is spawned whenever relay hosts exist, even if crawler_enabled is false. Inside Crawler::run, the retry thread is started unconditionally and repeatedly calls process_retry_queue without checking the watch flag. Only the per-relay crawl loop calls wait_enabled(). As a result, setting HYDRANT_ENABLE_CRAWLER=false, starting in filter mode where the crawler defaults to disabled, or PATCHing /ingestion to pause the crawler does not fully stop crawler activity if retry entries already exist in the database. Those entries can trigger batches of up to 1000 signal checks, which perform identity resolution and outbound describeRepo HTTP requests to PDS URLs controlled by DID documents. This defeats the intended runtime/config safety control and can keep causing outbound-network and resource-consumption activity after an operator believes the crawler is paused.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:31:59Z","created_by":"dawn","updated_at":"2026-06-27T20:31:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-p2e","title":"404 describeRepo responses can throttle PDS crawling","description":"Finding: 404 describeRepo responses can throttle PDS crawling\nSeverity: MEDIUM\nOriginal Finding ID: 16f60348cb78819183bd0f88d79d7efd\nIntroducing Commit: f9fea797e5ac3aaff3395015aafcb5a7eaca48a2\nRelevant Paths: src/util/mod.rs | src/crawler/list_repos.rs | src/util/throttle.rs\n\nDescription:\nThe crawler checks untrusted DIDs returned by listRepos by resolving each DID to a PDS and calling com.atproto.repo.describeRepo. Before this commit, 404 responses from describeRepo reached the explicit NOT_FOUND/GONE handling and were treated as NoSignal for that single DID. The new shared is_status_their_fault helper includes 404, and crawler is_throttle_worthy now delegates to it. As a result, a 404 describeRepo response is handled before the NOT_FOUND/GONE branch and calls throttle.record_failure(), which throttles the entire resolved PDS URL for 30 minutes with exponential backoff. A malicious crawler source, or an attacker able to get a DID with a service endpoint pointing at a victim PDS into a crawled listRepos stream, can make the victim PDS return 404 for that DID and suppress signal checks/discovery for other legitimate repos on that PDS in filter mode. The intended 525 addition should not have pulled 404 into this crawler path; 404 should remain endpoint-level throttle-worthy for firehose upgrade failures only, or the crawler helper should exclude 404 so repo-not-found is handled as NoSignal.","status":"open","priority":2,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:31:58Z","created_by":"dawn","updated_at":"2026-06-27T20:31:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-gvr","title":"Stream replay panics on malformed record paths","description":"Finding: Stream replay panics on malformed record paths\nSeverity: LOW\nOriginal Finding ID: fcef126dfbe48191a5af9f1eecb8fd16\nIntroducing Commit: 6dd9a8cec8525732485c2235ac6dd56ba1937eba\nRelevant Paths: src/control.rs | src/ops.rs | src/api/stream.rs\n\nDescription:\nRecord events are built for /stream subscribers by replaying StoredEvent values from the events keyspace. This commit changed stored_to_event to construct Nsid and Rkey values and to panic with expect() if either value is invalid. However, apply_commit still obtains collection and rkey by only splitting the firehose op.path on '/', then stores those raw components into StoredEvent. If an untrusted or malicious firehose/PDS source supplies an op path such as an empty collection, invalid NSID, or invalid record key, the event can be persisted successfully but later cause stored_to_event to panic. The panic occurs inside the per-subscriber hydrant-stream thread, so it is not a full process abort in the normal panic configuration, but it can disconnect live subscribers and make cursor replay fail at that event.","status":"open","priority":3,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:08Z","created_by":"dawn","updated_at":"2026-06-27T20:32:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-b0v","title":"Public requestCrawl can poison filter config via PDS status keys","description":"Finding: Public requestCrawl can poison filter config via PDS status keys\nSeverity: LOW\nOriginal Finding ID: 63456bb21eb4819193ca69d1a164aef5\nIntroducing Commit: c4d1302302c501bfecb9a02172c690b7be954179\nRelevant Paths: src/db/pds_meta.rs | src/db/filter.rs | src/state.rs | src/api/xrpc/request_crawl.rs | src/ingest/firehose.rs\n\nDescription:\nThe new PDS metadata layout removed the old `pt|`/`pb|` prefixes and now writes status keys as `{host}|status` into `state.db.filter`. That keyspace is already used for filter configuration where collection filters are stored as `c|{collection}` and signal filters as `s|{signal}`. Because public relay builds expose `com.atproto.sync.requestCrawl`, an unauthenticated client can submit hostname `c`; Hydrant starts a direct PDS ingestor for `wss://c/`, and after repeated connection failures the new code persists `HostStatus::Offline` to key `c|status`. On the next startup, `db::filter::load` scans `c|` keys and treats `c|status` as an operator-configured collection allowlist entry. Since an empty collection list normally means store all collections, this injected single entry changes behavior to store only the `status` collection, effectively dropping normal indexed records. The same un-namespaced layout also creates related collisions such as `s|status` for signal filters and `c|tier` for tier records.","status":"open","priority":3,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:07Z","created_by":"dawn","updated_at":"2026-06-27T20:32:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-ao2","title":"Malformed commits can poison resync buffers","description":"Finding: Malformed commits can poison resync buffers\nSeverity: LOW\nOriginal Finding ID: fb188b2a29508191a0544ec3a46b8406\nIntroducing Commit: 8c239314f96cdf25a9eb9c1d6b98b4565e2a5b6e\nRelevant Paths: src/ingest/worker.rs | src/ops.rs\n\nDescription:\nThe new IngestError wrapper converts miette::Report values into IngestError::Generic. ops::apply_commit returns miette::Result, and jacquard_repo CommitError values produced while parsing the repository commit are converted to miette reports inside ops::apply_commit. When process_commit uses `?` on apply_commit, those reports become IngestError::Generic, not IngestError::Commit. The new check_if_retriable_failure only matches top-level IngestError variants, so a Generic report containing a CommitError is now considered retriable. The worker then persists the failed commit to the resync_buffer. During later draining, the same malformed commit fails again and is not removed because it is still considered retriable, causing a sticky per-repository ingestion DoS and possible disk/log growth with repeated malformed revisions. Before this commit, check_if_retriable_failure downcasted the miette::Report directly and excluded CommitError from retries.","status":"open","priority":3,"issue_type":"bug","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:06Z","created_by":"dawn","updated_at":"2026-06-27T20:32:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-ydn","title":"listRepos emits a cursor for an empty next page","description":"Finding: listRepos emits a cursor for an empty next page\nSeverity: INFORMATIONAL\nOriginal Finding ID: bd48a15aa2348191a264838feb43e364\nIntroducing Commit: a85020dfa8913c6db8f49e953e1a0b491d438b9e\nRelevant Paths: src/api/xrpc/list_repos.rs | src/control/mod.rs | src/crawler/list_repos.rs\n\nDescription:\nThe new listRepos handler stops as soon as repos.len() reaches the limit and sets next_cursor to the DID of the last returned repo. If the remaining eligible repository count is exactly equal to the requested limit, the response still contains a cursor even though the next page will be empty. This is inconsistent with normal ATProto pagination semantics and with the existing listHosts implementation, which fetches one extra item to decide whether a next cursor should be returned. The direct impact is correctness: clients may perform unnecessary empty-page fetches, and Hydrant's own listRepos crawler has pre-existing cursor handling that can preserve the last cursor after an empty response, causing later passes to resume after the last DID instead of restarting from the beginning.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:15Z","created_by":"dawn","updated_at":"2026-06-27T20:32:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-bvr","title":"describeRepo now resolves handle identifiers twice","description":"Finding: describeRepo now resolves handle identifiers twice\nSeverity: INFORMATIONAL\nOriginal Finding ID: c8b82c8db1c481919813fd7089c041d5\nIntroducing Commit: 6dc1a1b2aea872da4c2f283c15eebdd82d2cf12e\nRelevant Paths: src/api/xrpc/describe_repo.rs | src/api/xrpc/resolve_mini_doc.rs\n\nDescription:\nIn the updated describeRepo handler, the new resolve_mini_doc future is created from the original identifier, then the handler separately resolves the same identifier to compute the repository whose collections will be returned. resolve_mini_doc also resolves the identifier internally when it is later awaited. For DID identifiers this is harmless, but for handle identifiers it causes duplicate outbound identity-resolution work. If a malicious or unstable handle resolves to different DIDs across the two lookups, the returned mini-doc fields can describe one DID while the collections field was read from another DID. This appears to be a correctness/resource regression rather than an exploitable security vulnerability under the provided threat model because the affected endpoint is public and the collection metadata is public.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:14Z","created_by":"dawn","updated_at":"2026-06-27T20:32:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-hn5","title":"listRecords cursor bounds ignore encoded rkey prefixes","description":"Finding: listRecords cursor bounds ignore encoded rkey prefixes\nSeverity: INFORMATIONAL\nOriginal Finding ID: d67e1043888c8191ba1ad05de897300c\nIntroducing Commit: a366d89f3dcf6fa716a7650a490e3ba344d95d7f\nRelevant Paths: src/db/keys.rs | src/api/xrpc.rs\n\nDescription:\nRecord keys are now stored as DID separator plus an encoded rkey where TID keys are prefixed with 't' and string keys are prefixed with 's'. However, handle_list_records still constructs range start/end keys by appending req.cursor.as_bytes() directly to the DID prefix. The response cursor is also converted back to the user-visible rkey string with to_smolstr(), so the next request does not contain the internal 't'/'s' byte needed to seek correctly in the keyspace. As a result, paginated listRecords requests can return empty pages, duplicate earlier results, or otherwise skip records after the first page. This is a public API correctness/regression issue rather than a security vulnerability.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:14Z","created_by":"dawn","updated_at":"2026-06-27T20:32:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-iwu","title":"Repo-state migration breaks older database upgrades","description":"Finding: Repo-state migration breaks older database upgrades\nSeverity: INFORMATIONAL\nOriginal Finding ID: 39ba414db56c8191bc2319e4adae1d8b\nIntroducing Commit: b4880fe5ebed0ffdb770edefbf8feba841d6d82d\nRelevant Paths: src/db/migration/v2.rs | src/types.rs\n\nDescription:\nThe commit adds a migration that rewrites old RepoState records into the new root-commit-based format. However, the migration's OldRepoState requires last_message_time with no serde default. The previous RepoState intentionally had #[serde(default)] on last_message_time, indicating older serialized database rows may not contain this field. When such a row is encountered, rmp_serde::from_slice returns an error and the migration aborts, preventing the service from opening the database after upgrade. This is an availability/upgrade compatibility bug, not a remotely exploitable security vulnerability.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:14Z","created_by":"dawn","updated_at":"2026-06-27T20:32:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-4gk","title":"Firehose cursors collide for same-host sources","description":"Finding: Firehose cursors collide for same-host sources\nSeverity: INFORMATIONAL\nOriginal Finding ID: b00bf3e59e6081918178a9eea3bf01b9\nIntroducing Commit: 0f356524c334164769a455ed80df073dbad5bef7\nRelevant Paths: src/db/keys/v1.rs | src/db/mod.rs | src/db/migration/v1.rs\n\nDescription:\nThe new cursor key helper stores and looks up cursors using only `url.host_str()`, dropping the scheme, port, and any other URL authority/path distinctions. `set_firehose_cursor`, `get_firehose_cursor`, reset, and the migration all use this hostname-only key. If Hydrant is configured with multiple firehose sources such as `wss://example.com` and `wss://example.com:8443`, their persisted cursors collide in the `cursors` keyspace. The last persisted source wins, and after restart either source may resume from the other's sequence, causing missed events or unnecessary replay. This is an introduced correctness/integrity bug, but it is not a direct security vulnerability under the provided threat model because firehose source configuration is operator/management-controlled and there is no default public path for an attacker to add colliding sources.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:13Z","created_by":"dawn","updated_at":"2026-06-27T20:32:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-cci","title":"README understates identity cache default","description":"Finding: README understates identity cache default\nSeverity: INFORMATIONAL\nOriginal Finding ID: 89bf4796a3dc8191b9a446f05b4ba4a4\nIntroducing Commit: ffbf4b888f3a74ed4bab4add23633bc04b3b2676\nRelevant Paths: README.md | src/config.rs\n\nDescription:\nThe README change says HYDRANT_IDENTITY_CACHE_SIZE defaults to 100000, matching the commit message's claim that the identity cache was decreased. However, Config::from_env still uses 1_000_000 as the default when HYDRANT_IDENTITY_CACHE_SIZE is unset. This is a configuration/documentation bug rather than a direct security vulnerability: operators relying on the updated documentation may underestimate the service's default memory footprint, but the commit does not introduce a new exploitable runtime behavior beyond the pre-existing larger cache default.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:13Z","created_by":"dawn","updated_at":"2026-06-27T20:32:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-wk1","title":"Non-security accountCount parsing bug in new script","description":"Finding: Non-security accountCount parsing bug in new script\nSeverity: INFORMATIONAL\nOriginal Finding ID: 2de0e62caf488191b37c93720ec13f0e\nIntroducing Commit: f44065b5e3544cdbdfd4607497e75316d8fcb111\nRelevant Paths: scripts/validate_failing_pds.nu | scripts/compare_relay_host_status.nu\n\nDescription:\nThe only substantive code changes are developer/operator Nushell scripts and a Nix flake lock update. I did not find an exploitable Hydrant service vulnerability introduced by this commit. However, scripts/validate_failing_pds.nu has a functional bug: on successful com.atproto.sync.getHostStatus responses it reads payload.account_count, while the companion script and the ATProto JSON field use accountCount. As a result, relay_account_count will be null for successful responses, making this script's account-count reporting inaccurate. This is a local maintenance/reporting issue, not a security boundary or remote exploit.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:13Z","created_by":"dawn","updated_at":"2026-06-27T20:32:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-64t","title":"FilterPatch drops repeated same-set mutations","description":"Finding: FilterPatch drops repeated same-set mutations\nSeverity: INFORMATIONAL\nOriginal Finding ID: 4a234a8a4b708191974aa993c4515482\nIntroducing Commit: 394c057ea652f751128dc2130f5c1647856c9d33\nRelevant Paths: src/control.rs\n\nDescription:\nThe commit changes FilterControl mutators to return a chainable FilterPatch. However, FilterPatch stores only one Option\u003cSetUpdate\u003e per set and every same-set method assigns a fresh Some(SetUpdate) to that field. As a result, chains such as add_exclude(did_a).add_exclude(did_b).apply() persist only did_b, and set_signals([a]).append_signals([b]).apply() loses the staged set of a. This is an introduced correctness bug in the library API. It is not a direct security vulnerability in the service threat model because the public PATCH /filter handler still maps each request body field once, management APIs are operator-controlled, and remote attackers cannot cause the buggy chaining pattern by themselves. The most security-adjacent impact is operator/library misconfiguration where intended excludes or collection restrictions are silently not applied.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:12Z","created_by":"dawn","updated_at":"2026-06-27T20:32:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-akc","title":"Enabled notification can skip PDS firehose throttling","description":"Finding: Enabled notification can skip PDS firehose throttling\nSeverity: INFORMATIONAL\nOriginal Finding ID: 842f35064fd8819192699098e6c0b563\nIntroducing Commit: 97bdeb2ec97e73f5445acec007837560073c5824\nRelevant Paths: src/ingest/firehose.rs | src/control/firehose.rs\n\nDescription:\nFor direct PDS firehose sources, each decoded message should wait for `throttle.wait_for_allow(...)` before being forwarded. The commit changed that wait to race against `self.enabled.changed()`. If the watch channel reports a change but the current value is true, the branch completes normally and execution falls through to `self.handle_message(msg).await` without `wait_for_allow` completing. This can happen on an explicit enable while already enabled, or if a false-to-true pause/resume is coalesced before this receiver observes the false value. The result is a rate-limit correctness bug: one message can be processed per such notification despite the PDS rate tier still being exhausted. Under the provided threat model, enabled-state changes are management/operator-controlled, so this is not a directly exploitable public security vulnerability unless management routes are intentionally exposed.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:12Z","created_by":"dawn","updated_at":"2026-06-27T20:32:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-7ab","title":"Duplicate method breaks combined relay/indexer builds","description":"Finding: Duplicate method breaks combined relay/indexer builds\nSeverity: INFORMATIONAL\nOriginal Finding ID: 6064311df05c8191a80e515743eb5d53\nIntroducing Commit: 904ecb280c15a017d60529b5fdb0e1bfb581ab90\nRelevant Paths: src/control/indexer.rs | src/control/relay.rs | src/control/mod.rs\n\nDescription:\nThis is a build-time regression rather than an exploitable runtime security vulnerability. The default feature set includes indexer_stream, and a common way to enable relay support is `--features relay`, which preserves default features. Under that configuration, `src/control/indexer.rs` and `src/control/relay.rs` both define `pub(crate) fn stream_send_timeout(\u0026self) -\u003e Duration` on `Hydrant`, causing a duplicate definition error. The helper should be defined once in a common Hydrant impl, or one of the definitions should be cfg-gated so the two cannot coexist.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:11Z","created_by":"dawn","updated_at":"2026-06-27T20:32:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-bb9","title":"Docker image data volume is not writable by hydrant user","description":"Finding: Docker image data volume is not writable by hydrant user\nSeverity: INFORMATIONAL\nOriginal Finding ID: 90d048d386a08191809e6aa85c2b915b\nIntroducing Commit: e7c8c5bec48b50f6926146eb30e4b0c76a284b63\nRelevant Paths: Dockerfile | src/state.rs | src/db/mod.rs\n\nDescription:\nThe Dockerfile creates a system user, sets HYDRANT_DATABASE_PATH to /data/hydrant.db, declares /data as a Docker volume, and then switches to USER hydrant. Because the Dockerfile never creates /data with hydrant ownership or runs chown/chmod on it, Docker-created anonymous or named volumes are initialized as root-owned directories. Hydrant opens the Fjall database at the configured path during startup, so the non-root process cannot create /data/hydrant.db in the default image configuration. This is an introduced container availability/packaging bug rather than a remotely exploitable security vulnerability.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:11Z","created_by":"dawn","updated_at":"2026-06-27T20:32:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-b5n","title":"Crawler swallows DB and parse errors after timeout refactor","description":"Finding: Crawler swallows DB and parse errors after timeout refactor\nSeverity: INFORMATIONAL\nOriginal Finding ID: 6d1fe9e94e008191a71d23676ec28721\nIntroducing Commit: 80f5c52961f8d1a42f03a3f776ba543caaa22383\nRelevant Paths: src/crawler/mod.rs\n\nDescription:\nThe new timeout wrappers changed the shape of the results returned from spawn_blocking. For listRepos parsing, the inner miette::Result from the blocking task is no longer unwrapped with `??`; instead the code pattern-matches only `Ok(Some(...))` and treats every other outcome, including an inner DB/keyspace error, as end-of-enumeration or an empty page. For batch commits, an inner `batch.commit()` error is logged via `inspect_err` and then discarded with `.ok()`, after which the crawler still calls `account_new_repos()`. This can leave crawler state, pending queues, cursors, and counters inconsistent after a storage/write failure. The timeout error mapping is also misleading because the `map_err` after `.into_diagnostic()?` handles JoinError, not tokio timeout expiry.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:10Z","created_by":"dawn","updated_at":"2026-06-27T20:32:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-0g9","title":"Crawler mode rename breaks persisted relay sources","description":"Finding: Crawler mode rename breaks persisted relay sources\nSeverity: INFORMATIONAL\nOriginal Finding ID: 62791deaf33c8191a747e64728bbc2e2\nIntroducing Commit: 980617379e3ee7040bdc387318fbf29752b3042e\nRelevant Paths: src/config.rs | src/control/crawler.rs | src/db/mod.rs | src/control/mod.rs\n\nDescription:\nCrawlerMode serialization/deserialization is string-based. Before this rename, dynamically added crawler sources were persisted with the mode string \"relay\". After the commit, FromStr only accepts \"list_repos\"/\"list-repos\" and \"by_collection\"/\"by-collection\". On startup, load_persisted_crawler_sources deserializes every persisted source and propagates any error, so a database containing an old \"relay\" source can make Hydrant fail to start until the DB entry is removed or migrated. Separately, HYDRANT_CRAWLER_URLS entries like relay::wss://... are now silently dropped because config parsing uses filter_map over CrawlerSource::parse. This is an introduced compatibility/availability bug, but under the provided threat model it is not a security vulnerability because it requires operator-controlled configuration or pre-existing local persisted state, not public attacker input.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:09Z","created_by":"dawn","updated_at":"2026-06-27T20:32:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-56c","title":"Backlinks DID filter is parsed but not applied","description":"Finding: Backlinks DID filter is parsed but not applied\nSeverity: INFORMATIONAL\nOriginal Finding ID: e502d96316a48191bcc007d6e2dfe3c5\nIntroducing Commit: 3ad938b38ca8e8012a43b204ff2ab39adf00f863\nRelevant Paths: src/backlinks/api.rs | src/backlinks/mod.rs\n\nDescription:\nThe new `did` fields in `GetBacklinksParams` and `GetBacklinksCountParams` are deserialized from public query parameters. `BacklinksFetch` and `BacklinksCount` also gained `.dids(...)` methods that implement filtering. However, `handle_get_backlinks` only applies `subject`, `limit`, `reverse`, `source`, and `cursor`, and `handle_get_backlinks_count` only applies `subject` and `source`. Neither handler calls `.dids(params.did)`. As a result, `?did=...` is silently ignored by both endpoints. This is a functional correctness bug introduced by the commit, not a Hydrant security vulnerability under the provided threat model because backlinks data is public and omitting the filter already returns the same data.","status":"open","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:32:09Z","created_by":"dawn","updated_at":"2026-06-27T20:32:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hydrant-7ra","title":"Test Issue","description":"This is a test issue","status":"closed","priority":4,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T20:31:34Z","created_by":"dawn","updated_at":"2026-06-27T20:31:51Z","closed_at":"2026-06-27T20:31:51Z","close_reason":"test","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index fe35810..02f2e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,3 +259,25 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + + +## Beads Issue Tracker + +Use Beads (`bd`) for durable task tracking in repositories that include it. Use the `beads` skill at `.agents/skills/beads/SKILL.md` (project install) or `~/.agents/skills/beads/SKILL.md` (global install) for Beads workflow guidance, then use the `bd` CLI for issue operations. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +bd prime # Refresh Beads context +``` + +### Rules + +- Use `bd` for all task tracking; do not create markdown TODO lists. +- Run `bd prime` when Beads context is missing or stale. +- Keep persistent project memory in Beads via `bd remember`; do not create ad hoc memory files. + diff --git a/src/backfill/worker/process.rs b/src/backfill/worker/process.rs index a89b47e..d3350b1 100644 --- a/src/backfill/worker/process.rs +++ b/src/backfill/worker/process.rs @@ -321,6 +321,11 @@ pub(crate) async fn process_did( "fetched car bytes" ); + // TODO: enforce a max_car_body_bytes limit here before parsing to prevent a malicious + // or compromised PDS from returning an unbounded response and causing OOM. + // also apply to the sparse path in sparse.rs. + // (finding d87fbd22402c81919fceb9cdfee53cd4) + // 3. import repo let start = Instant::now(); let parsed = jacquard_repo::car::reader::parse_car_bytes(&car_bytes.body) -- 2.51.2