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.
- agent_core_inbound/__init__.py +4 -0
- agent_core_inbound/_path.py +39 -0
- agent_core_inbound/audit.py +67 -0
- agent_core_inbound/endpoint.py +214 -0
- agent_core_inbound/funnel_handler.py +126 -0
- agent_core_inbound/github_allowance.py +95 -0
- agent_core_inbound/github_connector.py +218 -0
- agent_core_inbound/github_event.py +33 -0
- agent_core_inbound/plugin.py +20 -0
- agent_core_inbound/protocol.py +42 -0
- agent_core_inbound/router.py +228 -0
- agent_core_inbound/testing/__init__.py +9 -0
- agent_core_inbound/testing/fake_connector.py +63 -0
- agent_core_inbound/types.py +65 -0
- agent_core_inbound-0.8.2.dist-info/METADATA +132 -0
- agent_core_inbound-0.8.2.dist-info/RECORD +18 -0
- agent_core_inbound-0.8.2.dist-info/WHEEL +4 -0
- agent_core_inbound-0.8.2.dist-info/entry_points.txt +2 -0
|
@@ -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)
|