agent-core-inbound 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """Inbound notifications — deny-by-default external-event router.
2
+
3
+ See docs/superpowers/specs/2026-06-20-inbound-notifications-design.md.
4
+ """
@@ -0,0 +1,39 @@
1
+ """Dotted-path resolver for the GitHubConnector matching engine.
2
+
3
+ Walks a nested dict by splitting a dotted path into keys. Returns the
4
+ sentinel ``MISSING`` for any path that doesn't resolve cleanly —
5
+ including paths through non-dict cursor values, missing keys, or the
6
+ empty path. Falsy values (False, 0, "", None, [], {}) are NOT treated
7
+ as missing; they're real values the resolver returns directly.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Final
12
+
13
+
14
+ class _MissingSentinel:
15
+ __slots__ = ()
16
+
17
+ def __repr__(self) -> str:
18
+ return "MISSING"
19
+
20
+
21
+ MISSING: Final = _MissingSentinel()
22
+
23
+
24
+ def resolve_path(payload: dict[str, Any], path: str) -> Any:
25
+ """Resolve a dotted path against a nested dict.
26
+
27
+ Returns ``MISSING`` for any path that doesn't fully resolve.
28
+ """
29
+ if not path:
30
+ return MISSING
31
+ cursor: Any = payload
32
+ for key in path.split("."):
33
+ if not isinstance(cursor, dict) or key not in cursor:
34
+ return MISSING
35
+ cursor = cursor[key]
36
+ return cursor
37
+
38
+
39
+ __all__ = ["MISSING", "resolve_path"]
@@ -0,0 +1,67 @@
1
+ """JSONL audit log for inbound-notifications router.
2
+
3
+ One line per classification. Allow lines carry tier + reason +
4
+ rule_id; Deny lines carry only the timestamp + source + target.
5
+ Deny intentionally does NOT serialize the event body so a denied
6
+ inbound leaves no privacy-sensitive trace.
7
+ """
8
+ import json
9
+ from collections.abc import Callable
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agent_core_inbound.types import Allow
15
+
16
+
17
+ def _default_clock() -> datetime:
18
+ return datetime.now(UTC)
19
+
20
+
21
+ class AuditLog:
22
+ """Append-only JSONL writer keyed by absolute path.
23
+
24
+ ``clock`` is injectable so tests can pin the timestamp without
25
+ monkey-patching ``datetime.now``. Parent directories are created
26
+ on first write.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ path: Path,
33
+ clock: Callable[[], datetime] = _default_clock,
34
+ ) -> None:
35
+ self._path = path
36
+ self._clock = clock
37
+
38
+ def record_allow(
39
+ self,
40
+ *,
41
+ connector_name: str,
42
+ target_being: str,
43
+ verdict: Allow,
44
+ rule_id: str,
45
+ ) -> None:
46
+ self._write({
47
+ "ts": self._clock().isoformat(),
48
+ "source": connector_name,
49
+ "to": target_being,
50
+ "verdict": "allow",
51
+ "tier": verdict.tier.value,
52
+ "rule_id": rule_id,
53
+ "reason": verdict.reason,
54
+ })
55
+
56
+ def record_deny(self, *, connector_name: str, target_being: str) -> None:
57
+ self._write({
58
+ "ts": self._clock().isoformat(),
59
+ "source": connector_name,
60
+ "to": target_being,
61
+ "verdict": "deny",
62
+ })
63
+
64
+ def _write(self, entry: dict[str, Any]) -> None:
65
+ self._path.parent.mkdir(parents=True, exist_ok=True)
66
+ with self._path.open("a", encoding="utf-8") as fh:
67
+ fh.write(json.dumps(entry, separators=(",", ":")) + "\n")
@@ -0,0 +1,214 @@
1
+ """Daemon endpoint wrapper for the inbound-notifications router.
2
+
3
+ Wires the FastAPI Funnel handler + Router into the bus runner via the
4
+ ``register_endpoint_types`` hookimpl (see ``plugin.py``).
5
+
6
+ agent_core.yaml shape:
7
+
8
+ endpoints:
9
+ - type: inbound.github
10
+ name: inbound
11
+ params:
12
+ target_being: wren
13
+ listen_host: 127.0.0.1
14
+ listen_port: 8765
15
+ webhook_secret_env: FOREMAN_GITHUB_WEBHOOK_SECRET
16
+ github_allowance_path: ~/.wren/.config/inbound/github-allowance.toml
17
+ audit_log_path: ~/.wren/state/inbound-audit.jsonl
18
+ rate_limit_per_minute: 30
19
+
20
+ Tailscale Funnel is configured OUT-OF-PROCESS via the operator's
21
+ `tailscale funnel <port>` command pointing at ``listen_port``. The
22
+ endpoint binds to ``listen_host`` (default 127.0.0.1) so the only
23
+ public reach is through the tailnet-issued Funnel URL.
24
+
25
+ Inbound is push-only — no peer endpoint addresses envelopes to it on
26
+ the bus — so ``deliver`` just warns + acks anything that arrives.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import logging
32
+ import uuid
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ import uvicorn
38
+
39
+ from agent_core_credentials.secrets import SecretNotFoundError
40
+ from agent_core_credentials.secrets import get as get_secret
41
+ from agent_core_inbound.audit import AuditLog
42
+ from agent_core_inbound.funnel_handler import build_funnel_app
43
+ from agent_core_inbound.github_connector import GitHubConnector
44
+ from agent_core_inbound.router import Router
45
+
46
+ if TYPE_CHECKING:
47
+ from agent_core.bus.envelope import Envelope
48
+ from agent_core.bus.handle import BusHandle
49
+
50
+
51
+ log = logging.getLogger(__name__)
52
+
53
+
54
+ class InboundEndpoint:
55
+ """Daemon-lifecycle wrapper for the inbound-notifications router."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ name: str,
61
+ target_being: str,
62
+ listen_host: str,
63
+ listen_port: int,
64
+ webhook_secret_env: str,
65
+ github_allowance_path: str,
66
+ audit_log_path: str,
67
+ rate_limit_per_minute: int = 30,
68
+ ) -> None:
69
+ self.name = name # Protocol requires `name` attribute
70
+ self._target_being = target_being
71
+ self._listen_host = listen_host
72
+ self._listen_port = listen_port
73
+ self._webhook_secret_env = webhook_secret_env
74
+ self._github_allowance_path = github_allowance_path
75
+ self._audit_log_path = audit_log_path
76
+ self._rate_limit_per_minute = rate_limit_per_minute
77
+
78
+ # Validate secret at construction (fail-loud at config-load,
79
+ # not at first GitHub delivery).
80
+ try:
81
+ secret = get_secret(webhook_secret_env)
82
+ except SecretNotFoundError:
83
+ raise RuntimeError(
84
+ f"inbound endpoint {name!r}: env var {webhook_secret_env} not set "
85
+ f"(needed for GitHub webhook HMAC signature verification)"
86
+ ) from None
87
+ self._webhook_secret = secret.encode("utf-8")
88
+
89
+ # Defer Router + funnel app construction to start(); they need
90
+ # the BusHandle for bus_publish_adapter.
91
+ self._handle: BusHandle | None = None
92
+ self._server: uvicorn.Server | None = None
93
+ self._serve_task: asyncio.Task | None = None
94
+
95
+ async def start(self, bus: BusHandle) -> None:
96
+ """Bus is ready — build the Router/app and start uvicorn."""
97
+ self._handle = bus
98
+
99
+ connector = GitHubConnector(
100
+ config_path=Path(self._github_allowance_path).expanduser(),
101
+ principal_being=self._target_being,
102
+ )
103
+ audit = AuditLog(path=Path(self._audit_log_path).expanduser())
104
+ router = Router(
105
+ connectors={"github": connector},
106
+ bus_publish=self._bus_publish_adapter,
107
+ audit=audit,
108
+ rate_limits={
109
+ ("github", self._target_being): (self._rate_limit_per_minute, 60.0)
110
+ },
111
+ )
112
+ app = build_funnel_app(
113
+ router=router,
114
+ webhook_secret=self._webhook_secret,
115
+ target_being=self._target_being,
116
+ )
117
+ config = uvicorn.Config(
118
+ app,
119
+ host=self._listen_host,
120
+ port=self._listen_port,
121
+ log_level="info",
122
+ access_log=False,
123
+ )
124
+ self._server = uvicorn.Server(config)
125
+ self._serve_task = asyncio.create_task(self._server.serve())
126
+ log.info(
127
+ "InboundEndpoint(name=%s) started on %s:%s",
128
+ self.name,
129
+ self._listen_host,
130
+ self._listen_port,
131
+ )
132
+
133
+ async def deliver(self, envelope: Envelope) -> None:
134
+ """Inbound is push-only — no peer addresses envelopes here.
135
+
136
+ * Before start (_handle is None): raises EndpointUnavailable so the bus
137
+ requeues the envelope (transient — the endpoint is not yet ready).
138
+ * After start (_handle is not None): raises RuntimeError so the bus
139
+ dead-letters the misrouted envelope. Acking would falsely mark the
140
+ envelope as "successfully delivered" and leave no DLQ record.
141
+ """
142
+ if self._handle is None:
143
+ from agent_core.bus.protocol import EndpointUnavailable
144
+ raise EndpointUnavailable(
145
+ f"inbound endpoint {self.name!r} not started"
146
+ )
147
+ log.warning(
148
+ "InboundEndpoint(name=%s) received unexpected envelope kind=%s from=%s — dead-lettering",
149
+ self.name,
150
+ envelope.kind,
151
+ envelope.from_,
152
+ )
153
+ raise RuntimeError(
154
+ f"inbound endpoint {self.name!r} received unexpected envelope kind={envelope.kind!r} "
155
+ f"from={envelope.from_!r}; inbound is push-only"
156
+ )
157
+
158
+ async def stop(self) -> None:
159
+ """Graceful shutdown — set should_exit and await up to 5s."""
160
+ if self._server is not None:
161
+ self._server.should_exit = True
162
+ if self._serve_task is not None:
163
+ try:
164
+ await asyncio.wait_for(self._serve_task, timeout=5.0)
165
+ except TimeoutError:
166
+ self._serve_task.cancel()
167
+ self._handle = None
168
+ log.info("InboundEndpoint(name=%s) stopped", self.name)
169
+
170
+ def _bus_publish_adapter(
171
+ self,
172
+ *,
173
+ to: str,
174
+ kind: str,
175
+ payload: dict[str, Any],
176
+ urgency: str,
177
+ ) -> None:
178
+ """Bridge Router's bus_publish callable to BusHandle.publish.
179
+
180
+ Router calls this sync from inside the FastAPI async handler, so
181
+ we are guaranteed to be on a running event loop. BusHandle.publish
182
+ is async; we schedule it as a task and return immediately so the
183
+ Router's sync path doesn't block on the bus enqueue.
184
+
185
+ Construction notes:
186
+ * "Notification" is a BUILTIN_KIND — the Envelope validator
187
+ requires the payload to validate against ``NotificationPayload``
188
+ (a raw dict would be rejected). We model_validate here so any
189
+ payload shape bug surfaces at the call site rather than as an
190
+ opaque Envelope construction error.
191
+ * ``from_`` is left default — the bus stamps it to ``self.name``
192
+ via BusHandle on publish.
193
+ * ``urgency`` is narrowed to ``Literal["green","yellow","red"]``
194
+ at the Envelope layer; the Router passes a str (tier.value),
195
+ so we trust the connector's tier vocabulary here.
196
+ """
197
+ if self._handle is None:
198
+ raise RuntimeError(
199
+ f"inbound endpoint {self.name!r}: bus_publish called before start"
200
+ )
201
+
202
+ from agent_core.bus.envelope import Envelope, NotificationPayload
203
+
204
+ notification = NotificationPayload.model_validate(payload)
205
+ envelope = Envelope(
206
+ id=uuid.uuid4().hex,
207
+ correlation_id=uuid.uuid4().hex,
208
+ to=to,
209
+ kind=kind,
210
+ payload=notification,
211
+ urgency=urgency,
212
+ created_at=datetime.now(UTC),
213
+ )
214
+ self._handle.spawn(self._handle.publish(envelope), name="inbound-bus-publish")
@@ -0,0 +1,126 @@
1
+ """Tailscale Funnel HTTPS endpoint — receives GitHub webhooks.
2
+
3
+ Validates X-Hub-Signature-256 via HMAC-SHA256 of the raw body with
4
+ the operator-configured webhook secret. Translates the payload into
5
+ a generic GitHubEvent and hands it to Router.receive(). The connector's
6
+ match engine walks ``raw`` via dotted paths, so every webhook event
7
+ parses successfully here — including events GitHub introduces after
8
+ this code shipped. Filtering happens at the connector layer, not here.
9
+
10
+ Bad/missing signatures return 401 — but GitHub treats any non-2xx as a
11
+ delivery failure and retries, which means a misconfigured secret will
12
+ retry forever; the operator's deploy checklist must verify the secret
13
+ pair end-to-end before going live.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ from datetime import UTC, datetime
21
+
22
+ from fastapi import FastAPI, Header, HTTPException, Request, status
23
+
24
+ from agent_core_inbound.github_event import GitHubEvent
25
+ from agent_core_inbound.router import Router
26
+
27
+
28
+ def build_funnel_app(
29
+ *,
30
+ router: Router,
31
+ webhook_secret: bytes,
32
+ target_being: str,
33
+ ) -> FastAPI:
34
+ """Construct the FastAPI app with the GitHub webhook handler bound.
35
+
36
+ Returned app exposes POST /github. Hand to uvicorn / the daemon
37
+ endpoint wrapper for serving.
38
+ """
39
+ app = FastAPI(docs_url=None, redoc_url=None)
40
+
41
+ @app.post("/github", status_code=status.HTTP_204_NO_CONTENT)
42
+ async def receive_github(
43
+ request: Request,
44
+ x_github_event: str | None = Header(default=None),
45
+ x_github_delivery: str | None = Header(default=None),
46
+ x_hub_signature_256: str | None = Header(default=None),
47
+ ) -> None:
48
+ raw = await request.body()
49
+ if not _verify_signature(raw, x_hub_signature_256, webhook_secret):
50
+ raise HTTPException(status_code=401, detail="bad signature")
51
+
52
+ # Guard against signed-but-malformed JSON bodies. Returning 400
53
+ # (rather than letting json.JSONDecodeError raise a 500) tells
54
+ # GitHub to stop retrying — a malformed body won't be fixed by
55
+ # redelivery, and 5xx triggers the indefinite retry loop noted
56
+ # in the module docstring.
57
+ try:
58
+ payload = json.loads(raw) if raw else {}
59
+ except json.JSONDecodeError:
60
+ raise HTTPException(status_code=400, detail="malformed JSON body")
61
+
62
+ delivery_id = x_github_delivery or ""
63
+ # event_id falls back to event_type + repo + action when GitHub
64
+ # omits X-GitHub-Delivery so the router's de-dupe key is still
65
+ # stable.
66
+ if delivery_id:
67
+ event_id = f"github-{delivery_id}"
68
+ else:
69
+ repo_full_name = (payload.get("repository") or {}).get(
70
+ "full_name", "",
71
+ )
72
+ action = payload.get("action", "")
73
+ event_id = (
74
+ f"github-{repo_full_name}-{x_github_event or ''}-{action}"
75
+ )
76
+
77
+ event = _parse_event(
78
+ {"X-GitHub-Event": x_github_event or ""},
79
+ raw,
80
+ event_id=event_id,
81
+ )
82
+
83
+ router.receive(
84
+ connector_name="github",
85
+ target_being=target_being,
86
+ event=event,
87
+ )
88
+
89
+ return app
90
+
91
+
92
+ def _verify_signature(
93
+ body: bytes,
94
+ header_value: str | None,
95
+ secret: bytes,
96
+ ) -> bool:
97
+ if header_value is None or not header_value.startswith("sha256="):
98
+ return False
99
+ expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
100
+ presented = header_value.removeprefix("sha256=")
101
+ return hmac.compare_digest(expected, presented)
102
+
103
+
104
+ def _parse_event(
105
+ headers: dict[str, str],
106
+ body: bytes,
107
+ *,
108
+ event_id: str,
109
+ ) -> GitHubEvent:
110
+ """Parse a GitHub webhook into a generic event.
111
+
112
+ ``headers`` is the request headers map (case-insensitive lookup of
113
+ ``X-GitHub-Event`` to identify the event type). ``body`` is the raw
114
+ request body; we parse it as JSON and preserve the dict in
115
+ ``GitHubEvent.raw`` for downstream matchers.
116
+ """
117
+ event_type = headers.get("X-GitHub-Event") or headers.get("x-github-event") or ""
118
+ payload = json.loads(body) if body else {}
119
+ return GitHubEvent(
120
+ event_id=event_id,
121
+ landed_at=datetime.now(UTC),
122
+ event_type=event_type,
123
+ action=payload.get("action", ""),
124
+ repo_full_name=(payload.get("repository") or {}).get("full_name", ""),
125
+ raw=payload,
126
+ )
@@ -0,0 +1,95 @@
1
+ """TOML-driven GitHub allowance policy.
2
+
3
+ The principal being (Wren) edits this file directly to manage their
4
+ GitHub inbound rules. The router watches mtime and reloads on change
5
+ (see Task 10).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import tomllib
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
15
+
16
+ from agent_core_inbound.types import Tier
17
+
18
+
19
+ class AllowRule(BaseModel):
20
+ """One allowance rule. First-match-wins in classify()."""
21
+
22
+ rule_id: str = Field(min_length=1)
23
+ event: str = Field(min_length=1)
24
+ tier: Tier
25
+ reason: str = Field(min_length=1)
26
+
27
+ # Optional match constraints. All present constraints must match
28
+ # for the rule to apply. Missing constraints are wildcard.
29
+ repo: str | None = None
30
+ reviewer: str | None = None
31
+ label_name: str | None = None
32
+ match: dict[str, Any] | None = None
33
+
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ @model_validator(mode="before")
37
+ @classmethod
38
+ def _reject_body_contains(cls, data: Any) -> Any:
39
+ if isinstance(data, dict) and "body_contains" in data:
40
+ raise ValueError(
41
+ "AllowRule field 'body_contains' was removed in v2. "
42
+ "Use `match` with an exact-equality dotted path; substring "
43
+ "matching is deferred to v2.1 (`match_contains` operator)."
44
+ )
45
+ return data
46
+
47
+ @model_validator(mode="after")
48
+ def _translate_shortcuts(self) -> AllowRule:
49
+ # object.__setattr__ avoids Pydantic v2's re-validation trigger
50
+ # that self.x = ... would cause from inside an after-validator.
51
+ # Merge reviewer / label_name shortcuts into match, then null them.
52
+ # Existing match entries take precedence on key collision (shouldn't
53
+ # happen in practice — these are distinct field names).
54
+ added: dict[str, Any] = {}
55
+ if self.reviewer is not None:
56
+ added["requested_reviewer.login"] = self.reviewer
57
+ object.__setattr__(self, "reviewer", None)
58
+ if self.label_name is not None:
59
+ added["label.name"] = self.label_name
60
+ object.__setattr__(self, "label_name", None)
61
+ if added:
62
+ merged = dict(added)
63
+ if self.match is not None:
64
+ merged.update(self.match)
65
+ object.__setattr__(self, "match", merged)
66
+ return self
67
+
68
+
69
+ class AllowanceConfig(BaseModel):
70
+ """The complete allowance policy."""
71
+
72
+ allow: list[AllowRule] = Field(default_factory=list)
73
+
74
+ model_config = ConfigDict(extra="forbid")
75
+
76
+ @model_validator(mode="after")
77
+ def _enforce_unique_rule_ids(self) -> AllowanceConfig:
78
+ counts = Counter(r.rule_id for r in self.allow)
79
+ dups = [rid for rid, n in counts.items() if n > 1]
80
+ if dups:
81
+ raise ValueError(f"duplicate rule_id(s): {', '.join(sorted(dups))}")
82
+ return self
83
+
84
+
85
+ def load_allowance(path: Path) -> AllowanceConfig:
86
+ """Read + validate the TOML at ``path``.
87
+
88
+ Returns an empty config (allow=[]) for an empty or missing-rules
89
+ file. Validation errors propagate as ``pydantic.ValidationError``
90
+ for the caller to log / surface.
91
+ """
92
+ if not path.exists():
93
+ return AllowanceConfig(allow=[])
94
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
95
+ return AllowanceConfig.model_validate(data)