Skip to content

Commit f2b7476

Browse files
bgagentcursoragent
andcommitted
feat: implement event-driven governance across agent, CDK, and CLI (#230)
Add declarative event rules with sync gates in the agent runtime and async evaluation in FanOut, plus contracts, API/CLI surfaces, and platform-default rule pack resolution frozen on task creation. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 092361e commit f2b7476

63 files changed

Lines changed: 3616 additions & 12 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Event-driven governance (issue #230)."""
2+
3+
from event_governance.coordinator import evaluate_sync_event
4+
from event_governance.evaluator import EventRule, match_rules
5+
6+
__all__ = ["EventRule", "evaluate_sync_event", "match_rules"]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Load the normative event catalog from contracts/."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from functools import lru_cache
7+
from pathlib import Path
8+
from typing import Any
9+
10+
11+
def _catalog_path() -> Path:
12+
here = Path(__file__).resolve()
13+
for base in (here.parents[2], here.parents[1]):
14+
candidate = base / "contracts" / "event-catalog" / "v1.json"
15+
if candidate.is_file():
16+
return candidate
17+
deployed = Path("/app/contracts/event-catalog/v1.json")
18+
if deployed.is_file():
19+
return deployed
20+
raise FileNotFoundError("event catalog v1.json not found")
21+
22+
23+
@lru_cache(maxsize=1)
24+
def load_catalog() -> dict[str, Any]:
25+
"""Return the parsed event catalog (cached)."""
26+
with _catalog_path().open(encoding="utf-8") as fh:
27+
return json.load(fh)
28+
29+
30+
def is_known_event(name: str) -> bool:
31+
"""Return True when ``name`` appears in the catalog."""
32+
cat = load_catalog()
33+
return (
34+
name in cat.get("top_level_event_types", [])
35+
or name in cat.get("agent_milestones", [])
36+
or name in cat.get("checkpoints", [])
37+
)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Sync event governance coordinator — evaluate rules at pipeline checkpoints."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import Any
7+
8+
from event_governance.engine import build_policy_engine
9+
from event_governance.evaluator import match_rules, parse_rules
10+
from event_governance.gate import EventGateResult, gate_on_event_async
11+
from event_governance.writer import policy_decision_metadata, write_policy_decision
12+
from shell import log
13+
14+
15+
def _run_async(coro: Any) -> Any:
16+
try:
17+
asyncio.get_running_loop()
18+
except RuntimeError:
19+
return asyncio.run(coro)
20+
raise RuntimeError("evaluate_sync_event cannot run inside a running event loop")
21+
22+
23+
def evaluate_sync_event(
24+
*,
25+
rules_raw: list[dict[str, Any]] | None,
26+
event_type: str,
27+
metadata: dict[str, Any],
28+
progress: Any,
29+
rule_pack_id: str | None = None,
30+
config: Any = None,
31+
engine: Any = None,
32+
task_id: str | None = None,
33+
user_id: str | None = None,
34+
) -> EventGateResult | None:
35+
"""Evaluate sync rules for one event. Returns gate result when enforce blocks."""
36+
rules = parse_rules(rules_raw, rule_pack_id=rule_pack_id)
37+
matched = match_rules(rules, event_type=event_type, metadata=metadata, evaluation="sync")
38+
if not matched:
39+
return None
40+
41+
blocked: EventGateResult | None = None
42+
for rule in matched:
43+
enforce = rule.mode == "enforce"
44+
meta = policy_decision_metadata(
45+
rule=rule,
46+
event_type=event_type,
47+
metadata=metadata,
48+
enforce=enforce,
49+
)
50+
write_policy_decision(progress, event_type="policy_decision", metadata=meta)
51+
52+
if rule.action == "require_approval" and enforce:
53+
resolved_task_id = task_id or (config.task_id if config else None)
54+
if engine is None and config is not None:
55+
engine = build_policy_engine(config)
56+
if engine is None or not resolved_task_id:
57+
log("WARN", f"Event rule {rule.id} enforce blocked but engine/task_id missing")
58+
return EventGateResult(allowed=False, reason="approval system unavailable")
59+
result = _run_async(
60+
gate_on_event_async(
61+
rule=rule,
62+
event_type=event_type,
63+
metadata=metadata,
64+
engine=engine,
65+
task_id=resolved_task_id,
66+
user_id=user_id or (config.user_id if config else None),
67+
progress=progress,
68+
)
69+
)
70+
if not result.allowed:
71+
return result
72+
blocked = result
73+
return blocked
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Build PolicyEngine from TaskConfig for event governance gates."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from models import TaskConfig
9+
from policy import PolicyEngine
10+
11+
12+
def build_policy_engine(config: TaskConfig) -> PolicyEngine:
13+
"""Mirror runner.py PolicyEngine construction for checkpoint gates."""
14+
from policy import PolicyEngine
15+
16+
engine_kwargs: dict = {}
17+
if config.initial_approvals:
18+
engine_kwargs["initial_approvals"] = list(config.initial_approvals)
19+
if config.approval_timeout_s is not None:
20+
engine_kwargs["task_default_timeout_s"] = config.approval_timeout_s
21+
if config.initial_approval_gate_count:
22+
engine_kwargs["initial_approval_gate_count"] = config.initial_approval_gate_count
23+
if config.approval_gate_cap is not None:
24+
engine_kwargs["approval_gate_cap"] = config.approval_gate_cap
25+
cedar = config.cedar_policies if config.cedar_policies else None
26+
return PolicyEngine(
27+
task_type=config.policy_principal,
28+
repo=config.repo_url,
29+
read_only=config.read_only,
30+
extra_policies=cedar,
31+
**engine_kwargs,
32+
)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Declarative event rule matching (sync + async parity with CDK evaluator)."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from typing import Any
7+
8+
9+
@dataclass(frozen=True)
10+
class EventRule:
11+
"""One event governance rule from blueprint or registry pack."""
12+
13+
id: str
14+
on: str
15+
action: str
16+
mode: str
17+
evaluation: str
18+
when: dict[str, Any] = field(default_factory=dict)
19+
reason: str = ""
20+
severity: str = "medium"
21+
timeout_s: int | None = None
22+
notify_channels: list[str] = field(default_factory=list)
23+
rule_pack_id: str | None = None
24+
25+
26+
def _event_name(event_type: str, metadata: dict[str, Any]) -> str:
27+
"""Resolve the rule ``on`` key for an incoming event."""
28+
if event_type == "agent_milestone":
29+
milestone = metadata.get("milestone")
30+
if isinstance(milestone, str):
31+
return milestone
32+
checkpoint = metadata.get("checkpoint")
33+
if isinstance(checkpoint, str):
34+
return checkpoint
35+
return event_type
36+
37+
38+
def _fields_match(rule_when: dict[str, Any], metadata: dict[str, Any]) -> bool:
39+
expected = rule_when.get("fields")
40+
if not expected:
41+
return True
42+
if not isinstance(expected, dict):
43+
return False
44+
for key, want in expected.items():
45+
got = metadata.get(key)
46+
if got != want:
47+
return False
48+
return True
49+
50+
51+
def _aggregate_match(
52+
rule_when: dict[str, Any],
53+
metadata: dict[str, Any],
54+
aggregate_state: dict[str, Any] | None,
55+
) -> bool:
56+
agg = rule_when.get("aggregate")
57+
if not agg:
58+
return True
59+
if not isinstance(agg, dict):
60+
return False
61+
cost_gte = agg.get("cost_usd_gte")
62+
if cost_gte is not None:
63+
cumulative = aggregate_state.get("cumulative_cost_usd") if aggregate_state else None
64+
if cumulative is None:
65+
cumulative = metadata.get("cumulative_cost_usd")
66+
if cumulative is None:
67+
return False
68+
try:
69+
if float(cumulative) < float(cost_gte):
70+
return False
71+
except (TypeError, ValueError):
72+
return False
73+
turn_gte = agg.get("turn_count_gte")
74+
if turn_gte is not None:
75+
turns = aggregate_state.get("turn_count") if aggregate_state else None
76+
if turns is None:
77+
turns = metadata.get("turn_count")
78+
if turns is None:
79+
return False
80+
try:
81+
if float(turns) < float(turn_gte):
82+
return False
83+
except (TypeError, ValueError):
84+
return False
85+
return True
86+
87+
88+
def match_rules(
89+
rules: list[EventRule],
90+
*,
91+
event_type: str,
92+
metadata: dict[str, Any],
93+
evaluation: str | None = None,
94+
aggregate_state: dict[str, Any] | None = None,
95+
) -> list[EventRule]:
96+
"""Return rules that match the given event."""
97+
name = _event_name(event_type, metadata)
98+
matched: list[EventRule] = []
99+
for rule in rules:
100+
if rule.on != name:
101+
continue
102+
if evaluation is not None and rule.evaluation != evaluation:
103+
continue
104+
when = rule.when or {}
105+
if not _fields_match(when, metadata):
106+
continue
107+
if not _aggregate_match(when, metadata, aggregate_state):
108+
continue
109+
matched.append(rule)
110+
return matched
111+
112+
113+
def parse_rules(
114+
raw: list[dict[str, Any]] | None,
115+
*,
116+
rule_pack_id: str | None = None,
117+
) -> list[EventRule]:
118+
"""Parse blueprint/registry rule dicts into ``EventRule`` instances."""
119+
if not raw:
120+
return []
121+
out: list[EventRule] = []
122+
for item in raw:
123+
if not isinstance(item, dict) or not item.get("id"):
124+
continue
125+
out.append(
126+
EventRule(
127+
id=str(item["id"]),
128+
on=str(item["on"]),
129+
action=str(item.get("action", "observe_only")),
130+
mode=str(item.get("mode", "observe_only")),
131+
evaluation=str(item.get("evaluation", "sync")),
132+
when=dict(item.get("when") or {}),
133+
reason=str(item.get("reason") or ""),
134+
severity=str(item.get("severity") or "medium"),
135+
timeout_s=item.get("timeout_s"),
136+
notify_channels=list(item.get("notify_channels") or []),
137+
rule_pack_id=rule_pack_id or item.get("rule_pack_id"),
138+
)
139+
)
140+
return out

agent/src/event_governance/gate.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Event-sourced approval gates (Phase 2 enforce mode)."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING, Any
7+
8+
if TYPE_CHECKING:
9+
from event_governance.evaluator import EventRule
10+
11+
from hooks import _handle_require_approval
12+
from policy import PolicyDecision
13+
14+
15+
@dataclass
16+
class EventGateResult:
17+
"""Outcome of a sync event gate."""
18+
19+
allowed: bool
20+
reason: str = ""
21+
22+
23+
async def gate_on_event_async(
24+
*,
25+
rule: EventRule,
26+
event_type: str,
27+
metadata: dict[str, Any],
28+
engine: Any,
29+
task_id: str | None,
30+
user_id: str | None,
31+
progress: Any,
32+
ts_module: Any = None,
33+
) -> EventGateResult:
34+
"""Block on human approval for an event rule (enforce + require_approval)."""
35+
checkpoint = metadata.get("checkpoint") or rule.on
36+
tool_input = {"checkpoint": checkpoint, "event_type": event_type, **metadata}
37+
decision = PolicyDecision.require_approval(
38+
reason=rule.reason or f"Approval required at {checkpoint}",
39+
severity=rule.severity,
40+
timeout_s=rule.timeout_s or engine.task_default_timeout_s,
41+
matching_rule_ids=(rule.id,),
42+
)
43+
tool_name = f"event:{checkpoint}"
44+
response = await _handle_require_approval(
45+
decision=decision,
46+
tool_name=tool_name,
47+
tool_input=tool_input,
48+
engine=engine,
49+
task_id=task_id,
50+
user_id=user_id,
51+
progress=progress,
52+
ts=ts_module,
53+
approval_source="event",
54+
event_type=event_type,
55+
event_checkpoint=str(checkpoint),
56+
rule_id=rule.id,
57+
)
58+
allowed = response.get("hookSpecificOutput", {}).get("permissionDecision") == "allow"
59+
reason = response.get("hookSpecificOutput", {}).get("permissionDecisionReason", "")
60+
return EventGateResult(allowed=allowed, reason=reason or "")

0 commit comments

Comments
 (0)