fix(agent-team): harden github intake per /sh-security-review (CWE-918, idempotency)

Fixes from the high-recall detector fan-out on the durable-dedup change:

- INTAKE-LOGIC-01 (idempotency): switch the IngestStore seam from
  check-then-record (seen/mark) to claim-then-do (claim/release). The id is
  now reserved BEFORE the non-idempotent start_task side effect, so a crash in
  that window cannot re-spawn a duplicate task on the next run; a raising
  start_task releases the claim so transient failures stay retryable. Adds
  delete_issue_ingested to the schema layer for the release path.
- INTAKE-SSRF-001 / INTAKE-PATHSPLICE-002 (CWE-918) in build_default_issue_client:
  drop the caller-overridable api_root (hardcode GITHUB_API_ROOT) and validate
  owner/repo against an anchored charset before splicing them into the
  token-bearing API URL — mirrors the sibling ci_fetcher BLOCK-3/FIX-3 fixes.

Tests cover cross-process duplicate prevention, release-on-failure retry, and
the owner/repo + api_root rejection. 982 tests pass, ruff clean.

Follow-up (pre-existing, not introduced here): the label-only intake has no
author allowlist (cf. AGENT_TEAM_SLACK_OWNER_IDS on the Slack listener); the
Slack answer gate bounds the blast radius. Track as separate hardening.
This commit is contained in:
Adam Moussa 2026-06-22 17:06:56 -04:00
parent 3c8317b06e
commit ebc350aee5
3 changed files with 190 additions and 41 deletions

View file

@ -37,6 +37,7 @@ __all__ = [
"QUESTION_STATES", "QUESTION_STATES",
"answer_question", "answer_question",
"connect", "connect",
"delete_issue_ingested",
"expire_question", "expire_question",
"find_open_question_by_channel_ref", "find_open_question_by_channel_ref",
"init_db", "init_db",
@ -311,6 +312,22 @@ def record_issue_ingested(
return cur.rowcount > 0 return cur.rowcount > 0
def delete_issue_ingested(
conn: sqlite3.Connection, *, source: str, issue_id: str
) -> bool:
"""Undo a recorded ingest of ``(source, issue_id)``; True if a row was removed.
Used to RELEASE a claim made via :func:`record_issue_ingested` when the
downstream intake (``start_task``) raises, so a transiently-failed issue
stays eligible for retry on the next poll rather than being silently dropped.
"""
cur = conn.execute(
"DELETE FROM ingested_issues WHERE source = ? AND issue_id = ?",
(source, issue_id),
)
return cur.rowcount > 0
def answer_question( def answer_question(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,

View file

@ -36,8 +36,11 @@ De-duplication:
run and spawn duplicate tasks. The box is read-only (no write token to run and spawn duplicate tasks. The box is read-only (no write token to
remove the intake label), so durable de-dup is the only correct guard. remove the intake label), so durable de-dup is the only correct guard.
The id is recorded only AFTER ``start_task`` returns, so a failing intake The poller uses **claim-then-do**: it reserves the id on the store BEFORE
leaves the issue eligible for retry on the next poll rather than a silent drop. the non-idempotent ``start_task`` call, and releases the claim if that call
raises. So a transient failure stays retryable, a re-run never double-spawns,
and only a hard crash in the (tiny) window between claim and call drops the
issue — the safe failure for a read-only source whose label persists.
Design constraints honoured here (pre-deployment scaffolding): Design constraints honoured here (pre-deployment scaffolding):
* **No live infrastructure.** Nothing is provisioned or called at import. * **No live infrastructure.** Nothing is provisioned or called at import.
@ -56,6 +59,7 @@ Design constraints honoured here (pre-deployment scaffolding):
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any, Iterable, Protocol from typing import Any, Iterable, Protocol
__all__ = [ __all__ = [
@ -77,6 +81,17 @@ GITHUB_TRANSPORT_NAME = "github"
# time (never stored in source/state). Mirrors the github_adapter default. # time (never stored in source/state). Mirrors the github_adapter default.
DEFAULT_TOKEN_ENV = "GITHUB_TOKEN" DEFAULT_TOKEN_ENV = "GITHUB_TOKEN"
# The GitHub REST host, HARDCODED (mirrors ci_fetcher FIX-3): the production
# client does NOT accept a caller-overridable api_root, so a token-bearing GET
# can never be redirected at an attacker host / file:// (CWE-918).
GITHUB_API_ROOT = "https://api.github.com"
# owner/repo are spliced into the API URL path; validate them (anchored) before
# URL construction so a crafted owner/repo cannot splice extra path/query
# segments or traversal into the authenticated request (mirrors ci_fetcher's
# _OWNER_REPO_RE / BLOCK-3). GitHub logins and repo names are a subset of this.
_OWNER_REPO_RE = re.compile(r"[A-Za-z0-9_.-]{1,100}")
class GithubIssueClient(Protocol): class GithubIssueClient(Protocol):
"""Injected GitHub issue source: list open issues carrying a label. """Injected GitHub issue source: list open issues carrying a label.
@ -98,21 +113,27 @@ class IngestStore(Protocol):
A narrow seam so the poller's de-dup is swappable: a per-process in-memory A narrow seam so the poller's de-dup is swappable: a per-process in-memory
set for tests/one-off runs, or the durable SQLite ledger store for a set for tests/one-off runs, or the durable SQLite ledger store for a
scheduled intake (see the module docstring). ``seen``/``mark`` mirror the scheduled intake (see the module docstring).
check-then-record discipline; ``snapshot`` exposes the current id set for
introspection/logging. The contract is **claim-then-do** (not check-then-record): :meth:`claim`
atomically reserves an id and reports whether THIS caller won the claim, so
the id is reserved BEFORE the (non-idempotent) ``start_task`` side effect
runs — closing the window where a crash between starting a task and recording
the id would re-spawn a duplicate. :meth:`release` undoes a claim when
``start_task`` raises, keeping a transiently-failed issue retryable.
""" """
def seen(self, issue_id: str) -> bool: def claim(self, issue_id: str) -> bool:
"""Return True if ``issue_id`` has already been ingested.""" """Atomically reserve ``issue_id``. True if newly claimed (caller should
ingest); False if already claimed/ingested (caller skips)."""
... ...
def mark(self, issue_id: str) -> None: def release(self, issue_id: str) -> None:
"""Record ``issue_id`` as ingested (idempotent).""" """Undo a claim so the issue is retryable (used when ``start_task`` raises)."""
... ...
def snapshot(self) -> frozenset[str]: def snapshot(self) -> frozenset[str]:
"""Return a read-only snapshot of the ingested ids.""" """Return a read-only snapshot of the claimed/ingested ids."""
... ...
@ -127,11 +148,14 @@ class _InMemoryIngestStore:
def __init__(self) -> None: def __init__(self) -> None:
self._ids: set[str] = set() self._ids: set[str] = set()
def seen(self, issue_id: str) -> bool: def claim(self, issue_id: str) -> bool:
return issue_id in self._ids if issue_id in self._ids:
return False
def mark(self, issue_id: str) -> None:
self._ids.add(issue_id) self._ids.add(issue_id)
return True
def release(self, issue_id: str) -> None:
self._ids.discard(issue_id)
def snapshot(self) -> frozenset[str]: def snapshot(self) -> frozenset[str]:
return frozenset(self._ids) return frozenset(self._ids)
@ -248,17 +272,20 @@ class GithubIntake:
1. Ask the injected client for the open issues carrying the configured 1. Ask the injected client for the open issues carrying the configured
label (:meth:`GithubIssueClient.list_open_issues`). label (:meth:`GithubIssueClient.list_open_issues`).
2. For each issue NOT already in the in-memory ingested set, call 2. For each issue, **claim** its id on the store first
(:meth:`IngestStore.claim`); if the claim is lost (already
claimed/ingested) the issue is skipped. Only the winning claim calls
``coordinator.start_task(task_text=<title+body>, ``coordinator.start_task(task_text=<title+body>,
transport_name="github")`` and record its id so a subsequent poll transport_name="github")``.
does not re-ingest it.
Issues already ingested this process are skipped (the in-memory de-dup), Claim-then-do (not record-after): the id is reserved BEFORE the
and any issue the client returns without the label is *not* expected non-idempotent ``start_task`` side effect, so a crash between starting
(the client filters by label) but is ignored defensively if present. the task and finishing the pass cannot re-spawn a duplicate on the next
An issue id is recorded as ingested ONLY after ``start_task`` returns, run. If ``start_task`` *raises*, the claim is RELEASED so a transient
so a failing intake leaves the issue eligible for retry on the next poll failure stays retryable (no silent drop). The only residual is a hard
rather than silently dropping it. crash (SIGKILL/OOM/reboot) between the claim and the call, which drops
the issue rather than duplicating it — the safer failure for a read-only
source whose label persists for an operator to re-trigger.
Returns the list of issue ids ingested on THIS pass (empty when nothing Returns the list of issue ids ingested on THIS pass (empty when nothing
new), so an operator loop can log/meter intake volume. new), so an operator loop can log/meter intake volume.
@ -266,7 +293,7 @@ class GithubIntake:
ingested_now: list[str] = [] ingested_now: list[str] = []
for issue in self._client.list_open_issues(label=self._label): for issue in self._client.list_open_issues(label=self._label):
issue_id = _issue_id(issue) issue_id = _issue_id(issue)
if self._store.seen(issue_id): if not self._store.claim(issue_id):
_LOG.debug("github-intake: issue %s already ingested; skip", issue_id) _LOG.debug("github-intake: issue %s already ingested; skip", issue_id)
continue continue
@ -276,15 +303,18 @@ class GithubIntake:
issue_id, issue_id,
self._label, self._label,
) )
# start_task is the committed coordinator intake entry; the resulting # The claim above reserved the id BEFORE this non-idempotent intake
# task's clarifier question-sets route over the GitHub adapter. Record # call (coordinator.start_task mints a fresh thread + an open question
# the id only after the call returns so a raise leaves the issue # row). On a raise, RELEASE the claim so the transient failure is
# eligible for retry on the next poll (no silent drop). # retryable on the next poll rather than a silent drop.
self._coordinator.start_task( try:
task_text=task_text, self._coordinator.start_task(
transport_name=GITHUB_TRANSPORT_NAME, task_text=task_text,
) transport_name=GITHUB_TRANSPORT_NAME,
self._store.mark(issue_id) )
except Exception:
self._store.release(issue_id)
raise
ingested_now.append(issue_id) ingested_now.append(issue_id)
return ingested_now return ingested_now
@ -295,7 +325,6 @@ def build_default_issue_client(
owner: str, owner: str,
repo: str, repo: str,
token_env: str = DEFAULT_TOKEN_ENV, token_env: str = DEFAULT_TOKEN_ENV,
api_root: str = "https://api.github.com",
) -> GithubIssueClient: ) -> GithubIssueClient:
"""Build the production read-only issue client (deferred SDK/HTTP import). """Build the production read-only issue client (deferred SDK/HTTP import).
@ -307,8 +336,15 @@ def build_default_issue_client(
unit tests (which inject a fake client) never reach this path. unit tests (which inject a fake client) never reach this path.
The token is read from ``token_env`` at call time and sent as a bearer The token is read from ``token_env`` at call time and sent as a bearer
credential; it is never stored in source or logged. ``api_root`` is credential; it is never stored in source or logged.
overridable for GitHub Enterprise.
Security (mirrors ci_fetcher BLOCK-3 / FIX-3, CWE-918): the GitHub host is
HARDCODED (:data:`GITHUB_API_ROOT`) — there is no caller-overridable
``api_root``, so the token-bearing GET can never be redirected at an
attacker host or ``file://``. ``owner`` and ``repo`` are validated against
an anchored charset (:data:`_OWNER_REPO_RE`) BEFORE being spliced into the
URL path, failing closed so a crafted value cannot inject extra path/query
segments or traversal.
This is intentionally a thin, read-only lister: it issues a single GET to This is intentionally a thin, read-only lister: it issues a single GET to
the issues endpoint with ``state=open&labels=<label>`` and returns the the issues endpoint with ``state=open&labels=<label>`` and returns the
@ -316,6 +352,12 @@ def build_default_issue_client(
already carries ``id`` / ``number`` / ``title`` / ``body``). It performs no already carries ``id`` / ``number`` / ``title`` / ``body``). It performs no
CI, OIDC, write, or GitHub-Actions call; it only reads issues. CI, OIDC, write, or GitHub-Actions call; it only reads issues.
""" """
for _field, _value in (("owner", owner), ("repo", repo)):
if not _OWNER_REPO_RE.fullmatch(_value):
raise ValueError(
f"invalid GitHub {_field} {_value!r}: must match "
f"{_OWNER_REPO_RE.pattern} (refusing to build an unsafe API URL)"
)
class _RestIssueClient: class _RestIssueClient:
"""Stdlib-only GitHub REST issue lister (built lazily, no import-time HTTP).""" """Stdlib-only GitHub REST issue lister (built lazily, no import-time HTTP)."""
@ -324,7 +366,8 @@ def build_default_issue_client(
self._owner = owner self._owner = owner
self._repo = repo self._repo = repo
self._token_env = token_env self._token_env = token_env
self._api_root = api_root.rstrip("/") # Hardcoded host (no overridable api_root); owner/repo already validated.
self._api_root = GITHUB_API_ROOT
def list_open_issues(self, *, label: str) -> Iterable[dict[str, Any]]: def list_open_issues(self, *, label: str) -> Iterable[dict[str, Any]]:
import json import json
@ -391,13 +434,16 @@ def build_ledger_ingest_store(*, db_path: Any, source: str) -> IngestStore:
# not been init_db'd yet (idempotent CREATE IF NOT EXISTS). # not been init_db'd yet (idempotent CREATE IF NOT EXISTS).
self._conn.execute(_schema.INGESTED_ISSUES_DDL) self._conn.execute(_schema.INGESTED_ISSUES_DDL)
def seen(self, issue_id: str) -> bool: def claim(self, issue_id: str) -> bool:
return _schema.issue_already_ingested( # INSERT OR IGNORE under the (source, issue_id) primary key: returns
# True only if THIS call inserted the row (won the claim), so two
# concurrent pollers or a re-run can never both ingest the same issue.
return _schema.record_issue_ingested(
self._conn, source=self._source, issue_id=issue_id self._conn, source=self._source, issue_id=issue_id
) )
def mark(self, issue_id: str) -> None: def release(self, issue_id: str) -> None:
_schema.record_issue_ingested( _schema.delete_issue_ingested(
self._conn, source=self._source, issue_id=issue_id self._conn, source=self._source, issue_id=issue_id
) )

View file

@ -17,6 +17,7 @@ import pytest
from agent_team.transport.github_intake import ( from agent_team.transport.github_intake import (
GITHUB_TRANSPORT_NAME, GITHUB_TRANSPORT_NAME,
GithubIntake, GithubIntake,
build_default_issue_client,
build_ledger_ingest_store, build_ledger_ingest_store,
issue_task_text, issue_task_text,
) )
@ -304,3 +305,88 @@ def test_durable_store_namespaces_by_source(tmp_path: Path) -> None:
def test_build_ledger_ingest_store_rejects_empty_source(tmp_path: Path) -> None: def test_build_ledger_ingest_store_rejects_empty_source(tmp_path: Path) -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
build_ledger_ingest_store(db_path=tmp_path / "x.sqlite", source="") build_ledger_ingest_store(db_path=tmp_path / "x.sqlite", source="")
# --------------------------------------------------------------------------- #
# Claim-then-do durability (INTAKE-LOGIC-01 fix) + client hardening
# --------------------------------------------------------------------------- #
def test_durable_claim_without_release_prevents_duplicate(tmp_path: Path) -> None:
"""Claim-then-do: a claim recorded but never released (a crash around
start_task) must NOT re-ingest on a fresh process — no duplicate task."""
db = tmp_path / "ledger.sqlite"
source = "github:o/r"
# Simulate a poller that won the claim then 'crashed' before releasing.
store1 = build_ledger_ingest_store(db_path=db, source=source)
assert store1.claim("42") is True
assert store1.claim("42") is False # same store: re-claim loses
# Fresh process over the same DB: the claim persists, so poll skips it.
store2 = build_ledger_ingest_store(db_path=db, source=source)
coord = FakeCoordinator()
intake = GithubIntake(
client=FakeIssueClient([_issue(42)]),
coordinator=coord,
label=INTAKE_LABEL,
store=store2,
)
assert intake.poll_once() == []
assert coord.calls == []
def test_failed_start_task_releases_durable_claim_for_retry(tmp_path: Path) -> None:
"""A raising start_task releases the durable claim so a later poll retries."""
db = tmp_path / "ledger.sqlite"
source = "github:o/r"
class FlakyCoordinator:
def __init__(self) -> None:
self.attempts = 0
def start_task(self, *, task_text: str, transport_name: str) -> str:
self.attempts += 1
if self.attempts == 1:
raise RuntimeError("transient intake failure")
return "thread-ok"
coord = FlakyCoordinator()
intake = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord,
label=INTAKE_LABEL,
store=build_ledger_ingest_store(db_path=db, source=source),
)
with pytest.raises(RuntimeError):
intake.poll_once()
# The claim was released, so a fresh process sees the issue as unclaimed.
intake2 = GithubIntake(
client=FakeIssueClient([_issue(1)]),
coordinator=coord,
label=INTAKE_LABEL,
store=build_ledger_ingest_store(db_path=db, source=source),
)
assert intake2.poll_once() == ["1"]
assert coord.attempts == 2
def test_build_default_issue_client_rejects_unsafe_owner_repo() -> None:
"""owner/repo are validated before URL construction (path-splice / CWE-918)."""
with pytest.raises(ValueError):
build_default_issue_client(owner="../../..", repo="r")
with pytest.raises(ValueError):
build_default_issue_client(owner="o", repo="r/issues?labels=admin")
with pytest.raises(ValueError):
build_default_issue_client(owner="", repo="r")
# A normal owner/repo builds fine.
client = build_default_issue_client(
owner="Sea-Haven-Industries", repo="orchestrator"
)
assert hasattr(client, "list_open_issues")
def test_build_default_issue_client_rejects_api_root_kwarg() -> None:
"""No caller-overridable api_root (SSRF/host-redirect, mirrors ci_fetcher FIX-3)."""
with pytest.raises(TypeError):
build_default_issue_client(owner="o", repo="r", api_root="https://evil.example")