Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ sync:
condense: true # Haiku extraction for long notifications
allow_repos: [] # Guardrail allowlist — empty = all repos. Example: ["ClickHouse/*"]
deny_repos: [] # Guardrail denylist — always dropped (takes precedence)
allow_actors: [] # Guardrail allowlist of GitHub logins — empty = all. Example: ["alice", "bob"]
deny_actors: [] # Guardrail denylist of GitHub logins — always dropped (takes precedence)

github_events:
enabled: true
Expand Down Expand Up @@ -208,6 +210,30 @@ With `allow_repos` empty (the default), all repos pass — behavior is unchanged
| `github.allow_repos` | list | `[]` | Allowlist of repo globs. Empty = all repos pass |
| `github.deny_repos` | list | `[]` | Denylist of repo globs. Takes precedence over `allow_repos` |

### GitHub actor guardrail

The GitHub notification source also matches on `actors` — the list of every GitHub login
involved in a notification (issue/PR author, assignees, and comment/review authors). This
restricts **who** can put a notification in front of the worker, so a drive-by `@mention`
from an untrusted account is dropped before the agent ever sees it:

```yaml
sync:
github:
allow_actors: ["alice", "bob"] # Only notifications involving these logins reach the inbox
deny_actors: ["noisy-bot"] # Always dropped, even if otherwise allowed
```

`actors` is list-valued, so a notification is kept when **any** involved login matches the
allowlist. A non-empty `allow_actors` is **fail-closed**: a notification with no
identifiable actor (e.g. enrichment failed) is dropped. With `allow_actors` empty (the
default), all actors pass — behavior is unchanged. The repo and actor rules AND together.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `github.allow_actors` | list | `[]` | Allowlist of GitHub login globs. Empty = all actors pass |
| `github.deny_actors` | list | `[]` | Denylist of GitHub login globs. Takes precedence over `allow_actors` |

### Extending to other sources

Adding a guardrail to another source is a config field plus one registry line. In
Expand Down
10 changes: 10 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ class GitHubSyncConfig:
# pass); deny_repos is a denylist and takes precedence over allow_repos.
allow_repos: list[str] = field(default_factory=list)
deny_repos: list[str] = field(default_factory=list)
# Actor guardrails — limit which GitHub logins can land a notification in
# the inbox, matched on the "actors" metadata key (every login involved in
# the notification: issue/PR author, assignees, comment & review authors).
# Same semantics as allow_repos/deny_repos — case-insensitive globs, deny
# wins, and a non-empty allow_actors is fail-closed (a notification with no
# matching actor is dropped before it reaches the inbox). Empty = all pass.
allow_actors: list[str] = field(default_factory=list)
deny_actors: list[str] = field(default_factory=list)

@classmethod
def from_dict(cls, d: dict) -> GitHubSyncConfig:
Expand All @@ -284,6 +292,8 @@ def from_dict(cls, d: dict) -> GitHubSyncConfig:
condense=d.get("condense", False),
allow_repos=d.get("allow_repos", []),
deny_repos=d.get("deny_repos", []),
allow_actors=d.get("allow_actors", []),
deny_actors=d.get("deny_actors", []),
)


Expand Down
45 changes: 45 additions & 0 deletions nerve/sources/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,43 @@
_MAX_CONCURRENT_FETCHES = 5


def _collect_actors(
subject_user: str,
assignees: list[str],
comment: dict[str, Any] | None,
latest_review: dict[str, Any] | None,
inline_comments: list[dict[str, Any]],
recent_comments: list[dict[str, Any]],
) -> list[str]:
"""Every GitHub login involved in a notification, de-duplicated.

Order-preserving (first occurrence wins) with case-insensitive de-dup;
empty and placeholder ("?") logins are skipped. Surfaced as the ``actors``
metadata key so the inbox guardrail can allow/deny a notification by who is
involved (see :mod:`nerve.sources.filters`). The raw ``/notifications``
payload carries no actor, but enrichment has already fetched these logins
for the rendered content.
"""
candidates: list[str] = [subject_user, *assignees]
if comment:
candidates.append(comment.get("user", ""))
if latest_review:
candidates.append(latest_review.get("user", ""))
candidates.extend(ic.get("user", "") for ic in inline_comments)
candidates.extend(rc.get("user", "") for rc in recent_comments)

actors: list[str] = []
seen: set[str] = set()
for login in candidates:
if not login or login == "?":
continue
key = login.lower()
if key not in seen:
seen.add(key)
actors.append(login)
return actors


class GitHubSource(Source):
"""GitHub notification source using the gh CLI."""

Expand Down Expand Up @@ -181,6 +218,13 @@ async def fetch(self, cursor: str | None, limit: int = 100) -> FetchResult:
f"\n{rc_user} ({rc_date}):\n{rc_body}"
)

# Surface every involved login so the inbox guardrail can
# allow/deny by actor (the raw notification carries no actor).
actors = _collect_actors(
subject_user, assignees, comment, latest_review,
inline_comments, recent_comments,
)

records.append(SourceRecord(
id=notif.get("id", ""),
source="github",
Expand All @@ -195,6 +239,7 @@ async def fetch(self, cursor: str | None, limit: int = 100) -> FetchResult:
"subject_url": html_url,
"repo_name": repo_name,
"repo_url": repo.get("html_url", ""),
"actors": actors,
},
))

Expand Down
23 changes: 15 additions & 8 deletions nerve/sources/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,19 @@ def build_source_runners(
# GitHub (notifications)
gh = config.sync.github
if gh.enabled:
from nerve.sources.filters import InboxFilter
from nerve.sources.filters import FieldRule, InboxFilter
from nerve.sources.github import GitHubSource

source = GitHubSource()
# Guardrail: restrict which repos reach the inbox (matched on the
# "repo_name" metadata key set by GitHubSource).
gh_filter = InboxFilter.from_field(
"repo_name", allow=gh.allow_repos, deny=gh.deny_repos,
)
# Guardrails: restrict which repos (matched on the "repo_name" metadata
# key) and which GitHub actors (matched on the "actors" metadata key —
# every login involved in a notification) reach the inbox. The two rules
# AND together; within each, deny wins and a non-empty allow is
# fail-closed.
gh_filter = InboxFilter(rules=[
FieldRule(field="repo_name", allow=gh.allow_repos, deny=gh.deny_repos),
FieldRule(field="actors", allow=gh.allow_actors, deny=gh.deny_actors),
])
runners.append(SourceRunner(
source=source,
db=db,
Expand All @@ -107,8 +111,11 @@ def build_source_runners(
))
if gh_filter.active:
logger.info(
"Registered source: github (batch=%d, guardrail: allow=%s deny=%s)",
gh.batch_size, gh.allow_repos or "*", gh.deny_repos or [],
"Registered source: github (batch=%d, guardrail: "
"repos allow=%s deny=%s; actors allow=%s deny=%s)",
gh.batch_size,
gh.allow_repos or "*", gh.deny_repos or [],
gh.allow_actors or "*", gh.deny_actors or [],
)
else:
logger.info("Registered source: github (batch=%d)", gh.batch_size)
Expand Down
Loading