diff --git a/agent/event-rules-parity/README.md b/agent/event-rules-parity/README.md new file mode 100644 index 00000000..b31f442f --- /dev/null +++ b/agent/event-rules-parity/README.md @@ -0,0 +1,11 @@ +# Event rule evaluator parity fixtures + +Golden-file vectors shared by: + +- `agent/tests/test_event_rules_parity.py` (Python evaluator) +- `cdk/test/handlers/shared/event-rules-parity.test.ts` (TypeScript evaluator) + +Fixtures live under `agent/event-rules/fixtures/` and are referenced by +basename from this parity contract. + +Design reference: `docs/design/EVENT_GOVERNANCE.md` (issue #230). diff --git a/agent/event-rules/README.md b/agent/event-rules/README.md new file mode 100644 index 00000000..68d4574a --- /dev/null +++ b/agent/event-rules/README.md @@ -0,0 +1,29 @@ +# Event governance rules + +Event rule packs, schema, and cross-language parity fixtures for event-driven +governance (issue #230). Authored here alongside `agent/workflows/` — one asset +tree, one authoring UX. The related event-catalog and policy-decision schemas +stay under the repo-root `contracts/` tree. + +## Layout + +| Path | Purpose | +|------|---------| +| `schema.json` | JSON Schema for event rule packs | +| `packs/*.json` | Versioned rule packs pinned by `Blueprint.security.eventRulePack` | +| `fixtures/` | Golden fixtures for Python + TypeScript evaluator parity | + +## Consumers + +| Caller | Path | Reads | +|--------|------|-------| +| `cdk/src/handlers/shared/event-rule-pack-resolver.ts` | Bundles `packs/*.json` at build time (future `RegistryService.resolve`) | packs | +| `cdk/test/.../event-rule-evaluator.test.ts`, `event-rules-parity.test.ts` | TypeScript evaluator + parity | fixtures | +| `agent/tests/test_event_rules_parity.py`, `test_event_governance_evaluator.py` | Python evaluator + parity | fixtures | +| `cli/src/commands/rules.ts` | `bgagent rules eval --fixture` | fixtures | + +The agent runtime does **not** read these files: it receives already-resolved +`event_rules` frozen on the TaskRecord at submit time, so both evaluation planes +consume one source (the CDK-resolved rules). + +Design reference: [EVENT_GOVERNANCE.md](../../docs/design/EVENT_GOVERNANCE.md). diff --git a/agent/event-rules/fixtures/aggregate-cost-cancel.json b/agent/event-rules/fixtures/aggregate-cost-cancel.json new file mode 100644 index 00000000..ab1ae21e --- /dev/null +++ b/agent/event-rules/fixtures/aggregate-cost-cancel.json @@ -0,0 +1,28 @@ +{ + "description": "Aggregate cost ceiling triggers cancel", + "event": { + "event_type": "agent_cost_update", + "metadata": { + "cumulative_cost_usd": 26.5 + } + }, + "rules": [ + { + "id": "cost-ceiling", + "on": "agent_cost_update", + "when": { + "aggregate": { + "cost_usd_gte": 25 + } + }, + "action": "cancel_task", + "mode": "enforce", + "evaluation": "async", + "reason": "Cumulative cost exceeded $25" + } + ], + "expected_matches": ["cost-ceiling"], + "aggregate_state": { + "cumulative_cost_usd": 26.5 + } +} diff --git a/agent/event-rules/fixtures/aggregate-turn-count-escalate.json b/agent/event-rules/fixtures/aggregate-turn-count-escalate.json new file mode 100644 index 00000000..5b78ca44 --- /dev/null +++ b/agent/event-rules/fixtures/aggregate-turn-count-escalate.json @@ -0,0 +1,28 @@ +{ + "description": "Aggregate turn-count ceiling triggers escalate", + "event": { + "event_type": "agent_turn", + "metadata": { + "turn_count": 40 + } + }, + "rules": [ + { + "id": "turn-ceiling", + "on": "agent_turn", + "when": { + "aggregate": { + "turn_count_gte": 30 + } + }, + "action": "escalate", + "mode": "enforce", + "evaluation": "async", + "reason": "Turn count exceeded 30" + } + ], + "expected_matches": ["turn-ceiling"], + "aggregate_state": { + "turn_count": 40 + } +} diff --git a/agent/event-rules/fixtures/async-notify-pr.json b/agent/event-rules/fixtures/async-notify-pr.json new file mode 100644 index 00000000..ca186eb1 --- /dev/null +++ b/agent/event-rules/fixtures/async-notify-pr.json @@ -0,0 +1,21 @@ +{ + "description": "Async notify on pr_created", + "event": { + "event_type": "agent_milestone", + "metadata": { + "milestone": "pr_created", + "pr_url": "https://github.com/org/repo/pull/1" + } + }, + "rules": [ + { + "id": "notify-on-pr", + "on": "pr_created", + "action": "notify", + "mode": "enforce", + "evaluation": "async", + "notify_channels": ["slack"] + } + ], + "expected_matches": ["notify-on-pr"] +} diff --git a/agent/event-rules/fixtures/observe-repo-setup.json b/agent/event-rules/fixtures/observe-repo-setup.json new file mode 100644 index 00000000..d3db08ed --- /dev/null +++ b/agent/event-rules/fixtures/observe-repo-setup.json @@ -0,0 +1,27 @@ +{ + "description": "Sync observe-only rule fires on repo_setup_complete", + "event": { + "event_type": "agent_milestone", + "metadata": { + "milestone": "repo_setup_complete", + "workflow_ref": "coding/new-task-v1" + } + }, + "rules": [ + { + "id": "plan-review-before-code", + "on": "repo_setup_complete", + "when": { + "fields": { + "workflow_ref": "coding/new-task-v1" + } + }, + "action": "require_approval", + "mode": "observe_only", + "evaluation": "sync", + "reason": "Plan review before execution", + "severity": "medium" + } + ], + "expected_matches": ["plan-review-before-code"] +} diff --git a/agent/event-rules/packs/platform-default-v1.json b/agent/event-rules/packs/platform-default-v1.json new file mode 100644 index 00000000..179f3bfb --- /dev/null +++ b/agent/event-rules/packs/platform-default-v1.json @@ -0,0 +1,23 @@ +{ + "pack_id": "platform-default", + "pack_version": "1.0.0", + "rules": [ + { + "id": "observe-repo-setup", + "on": "repo_setup_complete", + "action": "require_approval", + "mode": "observe_only", + "evaluation": "sync", + "reason": "Plan review before execution (platform default — observe)", + "severity": "medium" + }, + { + "id": "notify-on-pr", + "on": "pr_created", + "action": "notify", + "mode": "enforce", + "evaluation": "async", + "notify_channels": ["slack"] + } + ] +} diff --git a/agent/event-rules/schema.json b/agent/event-rules/schema.json new file mode 100644 index 00000000..56198d2d --- /dev/null +++ b/agent/event-rules/schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abca.dev/agent/event-rules/schema.json", + "title": "EventRulePack", + "type": "object", + "required": ["rules"], + "properties": { + "pack_id": { "type": "string" }, + "pack_version": { "type": "string" }, + "rules": { + "type": "array", + "items": { "$ref": "#/$defs/EventRule" } + } + }, + "$defs": { + "EventRule": { + "type": "object", + "required": ["id", "on", "action", "mode", "evaluation"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "on": { "type": "string", "minLength": 1 }, + "when": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": true + }, + "aggregate": { + "type": "object", + "properties": { + "cost_usd_gte": { "type": "number", "minimum": 0 }, + "turn_count_gte": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "action": { + "type": "string", + "enum": [ + "require_approval", + "notify", + "escalate", + "cancel_task", + "inject_nudge", + "observe_only" + ] + }, + "mode": { + "type": "string", + "enum": ["observe_only", "enforce"] + }, + "evaluation": { + "type": "string", + "enum": ["sync", "async"] + }, + "reason": { "type": "string" }, + "severity": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "timeout_s": { "type": "integer", "minimum": 30, "maximum": 3600 }, + "notify_channels": { + "type": "array", + "items": { + "type": "string", + "enum": ["slack", "email", "github", "linear", "webhook"] + } + } + }, + "additionalProperties": false + } + } +} diff --git a/agent/src/event_governance/__init__.py b/agent/src/event_governance/__init__.py new file mode 100644 index 00000000..96d63486 --- /dev/null +++ b/agent/src/event_governance/__init__.py @@ -0,0 +1,6 @@ +"""Event-driven governance (issue #230).""" + +from event_governance.coordinator import evaluate_sync_event +from event_governance.evaluator import EventRule, match_rules + +__all__ = ["EventRule", "evaluate_sync_event", "match_rules"] diff --git a/agent/src/event_governance/catalog.py b/agent/src/event_governance/catalog.py new file mode 100644 index 00000000..5dadc4b9 --- /dev/null +++ b/agent/src/event_governance/catalog.py @@ -0,0 +1,37 @@ +"""Load the normative event catalog from contracts/.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + + +def _catalog_path() -> Path: + here = Path(__file__).resolve() + for base in (here.parents[2], here.parents[1]): + candidate = base / "contracts" / "event-catalog" / "v1.json" + if candidate.is_file(): + return candidate + deployed = Path("/app/contracts/event-catalog/v1.json") + if deployed.is_file(): + return deployed + raise FileNotFoundError("event catalog v1.json not found") + + +@lru_cache(maxsize=1) +def load_catalog() -> dict[str, Any]: + """Return the parsed event catalog (cached).""" + with _catalog_path().open(encoding="utf-8") as fh: + return json.load(fh) + + +def is_known_event(name: str) -> bool: + """Return True when ``name`` appears in the catalog.""" + cat = load_catalog() + return ( + name in cat.get("top_level_event_types", []) + or name in cat.get("agent_milestones", []) + or name in cat.get("checkpoints", []) + ) diff --git a/agent/src/event_governance/coordinator.py b/agent/src/event_governance/coordinator.py new file mode 100644 index 00000000..cbeb4e27 --- /dev/null +++ b/agent/src/event_governance/coordinator.py @@ -0,0 +1,73 @@ +"""Sync event governance coordinator — evaluate rules at pipeline checkpoints.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from event_governance.engine import build_policy_engine +from event_governance.evaluator import match_rules, parse_rules +from event_governance.gate import EventGateResult, gate_on_event_async +from event_governance.writer import policy_decision_metadata, write_policy_decision +from shell import log + + +def _run_async(coro: Any) -> Any: + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + raise RuntimeError("evaluate_sync_event cannot run inside a running event loop") + + +def evaluate_sync_event( + *, + rules_raw: list[dict[str, Any]] | None, + event_type: str, + metadata: dict[str, Any], + progress: Any, + rule_pack_id: str | None = None, + config: Any = None, + engine: Any = None, + task_id: str | None = None, + user_id: str | None = None, +) -> EventGateResult | None: + """Evaluate sync rules for one event. Returns gate result when enforce blocks.""" + rules = parse_rules(rules_raw, rule_pack_id=rule_pack_id) + matched = match_rules(rules, event_type=event_type, metadata=metadata, evaluation="sync") + if not matched: + return None + + blocked: EventGateResult | None = None + for rule in matched: + enforce = rule.mode == "enforce" + meta = policy_decision_metadata( + rule=rule, + event_type=event_type, + metadata=metadata, + enforce=enforce, + ) + write_policy_decision(progress, event_type="policy_decision", metadata=meta) + + if rule.action == "require_approval" and enforce: + resolved_task_id = task_id or (config.task_id if config else None) + if engine is None and config is not None: + engine = build_policy_engine(config) + if engine is None or not resolved_task_id: + log("WARN", f"Event rule {rule.id} enforce blocked but engine/task_id missing") + return EventGateResult(allowed=False, reason="approval system unavailable") + result = _run_async( + gate_on_event_async( + rule=rule, + event_type=event_type, + metadata=metadata, + engine=engine, + task_id=resolved_task_id, + user_id=user_id or (config.user_id if config else None), + progress=progress, + ) + ) + if not result.allowed: + return result + blocked = result + return blocked diff --git a/agent/src/event_governance/engine.py b/agent/src/event_governance/engine.py new file mode 100644 index 00000000..8d65c776 --- /dev/null +++ b/agent/src/event_governance/engine.py @@ -0,0 +1,32 @@ +"""Build PolicyEngine from TaskConfig for event governance gates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from models import TaskConfig + from policy import PolicyEngine + + +def build_policy_engine(config: TaskConfig) -> PolicyEngine: + """Mirror runner.py PolicyEngine construction for checkpoint gates.""" + from policy import PolicyEngine + + engine_kwargs: dict = {} + if config.initial_approvals: + engine_kwargs["initial_approvals"] = list(config.initial_approvals) + if config.approval_timeout_s is not None: + engine_kwargs["task_default_timeout_s"] = config.approval_timeout_s + if config.initial_approval_gate_count: + engine_kwargs["initial_approval_gate_count"] = config.initial_approval_gate_count + if config.approval_gate_cap is not None: + engine_kwargs["approval_gate_cap"] = config.approval_gate_cap + cedar = config.cedar_policies if config.cedar_policies else None + return PolicyEngine( + task_type=config.policy_principal, + repo=config.repo_url, + read_only=config.read_only, + extra_policies=cedar, + **engine_kwargs, + ) diff --git a/agent/src/event_governance/evaluator.py b/agent/src/event_governance/evaluator.py new file mode 100644 index 00000000..a92840db --- /dev/null +++ b/agent/src/event_governance/evaluator.py @@ -0,0 +1,175 @@ +"""Declarative event rule matching (sync + async parity with CDK evaluator).""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EventRule: + """One event governance rule from blueprint or registry pack.""" + + id: str + on: str + action: str + mode: str + evaluation: str + when: dict[str, Any] = field(default_factory=dict) + reason: str = "" + severity: str = "medium" + timeout_s: int | None = None + notify_channels: list[str] = field(default_factory=list) + rule_pack_id: str | None = None + + +def _event_name(event_type: str, metadata: dict[str, Any]) -> str: + """Resolve the rule ``on`` key for an incoming event.""" + if event_type == "agent_milestone": + milestone = metadata.get("milestone") + if isinstance(milestone, str): + return milestone + checkpoint = metadata.get("checkpoint") + if isinstance(checkpoint, str): + return checkpoint + return event_type + + +def _fields_match(rule_when: dict[str, Any], metadata: dict[str, Any]) -> bool: + expected = rule_when.get("fields") + if not expected: + return True + if not isinstance(expected, dict): + return False + for key, want in expected.items(): + got = metadata.get(key) + if got != want: + return False + return True + + +# Canonical aggregate metadata fields ↔ accepted aliases (base field a producer +# emits ↔ cumulative field the rule reads). Parity with the CDK evaluator's +# AGGREGATE_FIELDS: the evaluator normalizes so producers emit ONE name. See #230. +_AGGREGATE_ALIASES = { + "cost": ("cumulative_cost_usd", "cost_usd"), + "turns": ("turn_count", "turn"), +} + + +def _read_aggregate( + metadata: dict[str, Any], + aliases: tuple[str, ...], +) -> float | None: + """First value among ``aliases`` that coerces to a finite float.""" + for key in aliases: + raw = metadata.get(key) + if raw is None: + continue + try: + return float(raw) + except (TypeError, ValueError): + continue + return None + + +def _aggregate_match( + rule_when: dict[str, Any], + metadata: dict[str, Any], + aggregate_state: dict[str, Any] | None, +) -> bool: + agg = rule_when.get("aggregate") + if not agg: + return True + if not isinstance(agg, dict): + return False + cost_gte = agg.get("cost_usd_gte") + if cost_gte is not None: + cumulative = aggregate_state.get("cumulative_cost_usd") if aggregate_state else None + if cumulative is None: + cumulative = _read_aggregate(metadata, _AGGREGATE_ALIASES["cost"]) + if cumulative is None: + return False + try: + if float(cumulative) < float(cost_gte): + return False + except (TypeError, ValueError): + return False + turn_gte = agg.get("turn_count_gte") + if turn_gte is not None: + turns = aggregate_state.get("turn_count") if aggregate_state else None + if turns is None: + turns = _read_aggregate(metadata, _AGGREGATE_ALIASES["turns"]) + if turns is None: + return False + try: + if float(turns) < float(turn_gte): + return False + except (TypeError, ValueError): + return False + return True + + +def match_rules( + rules: list[EventRule], + *, + event_type: str, + metadata: dict[str, Any], + evaluation: str | None = None, + aggregate_state: dict[str, Any] | None = None, +) -> list[EventRule]: + """Return rules that match the given event.""" + name = _event_name(event_type, metadata) + matched: list[EventRule] = [] + for rule in rules: + if rule.on != name: + continue + if evaluation is not None and rule.evaluation != evaluation: + continue + when = rule.when or {} + if not _fields_match(when, metadata): + continue + if not _aggregate_match(when, metadata, aggregate_state): + continue + matched.append(rule) + return matched + + +def parse_rules( + raw: list[dict[str, Any]] | None, + *, + rule_pack_id: str | None = None, +) -> list[EventRule]: + """Parse blueprint/registry rule dicts into ``EventRule`` instances.""" + if not raw: + return [] + out: list[EventRule] = [] + for index, item in enumerate(raw): + # Require both id and on, matching the CDK parser — a rule missing either + # is dropped with a warning rather than silently vanishing or raising a + # KeyError mid-loop (a dropped ceiling/approval rule = zero enforcement). + if not isinstance(item, dict) or not item.get("id") or not item.get("on"): + logger.warning( + "[event-governance] dropped malformed rule — missing id/on (index=%s)", + index, + ) + continue + out.append( + EventRule( + id=str(item["id"]), + on=str(item["on"]), + action=str(item.get("action", "observe_only")), + mode=str(item.get("mode", "observe_only")), + evaluation=str(item.get("evaluation", "sync")), + when=dict(item.get("when") or {}), + reason=str(item.get("reason") or ""), + severity=str(item.get("severity") or "medium"), + timeout_s=item.get("timeout_s"), + notify_channels=list(item.get("notify_channels") or []), + rule_pack_id=rule_pack_id or item.get("rule_pack_id"), + ) + ) + return out diff --git a/agent/src/event_governance/gate.py b/agent/src/event_governance/gate.py new file mode 100644 index 00000000..3f32bdaa --- /dev/null +++ b/agent/src/event_governance/gate.py @@ -0,0 +1,66 @@ +"""Event-sourced approval gates (Phase 2 enforce mode).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from event_governance.evaluator import EventRule + +import task_state +from hooks import _handle_require_approval +from policy import PolicyDecision + + +@dataclass +class EventGateResult: + """Outcome of a sync event gate.""" + + allowed: bool + reason: str = "" + + +async def gate_on_event_async( + *, + rule: EventRule, + event_type: str, + metadata: dict[str, Any], + engine: Any, + task_id: str | None, + user_id: str | None, + progress: Any, + ts_module: Any = None, +) -> EventGateResult: + """Block on human approval for an event rule (enforce + require_approval).""" + # Default to the real task_state module. _handle_require_approval dereferences + # ``ts`` unconditionally (increment_approval_gate_count_in_ddb, + # transact_write_approval_request); a None here crashes the gate instead of + # pausing for approval. The parameter stays overridable for tests. See #230. + ts_module = ts_module if ts_module is not None else task_state + checkpoint = metadata.get("checkpoint") or rule.on + tool_input = {"checkpoint": checkpoint, "event_type": event_type, **metadata} + decision = PolicyDecision.require_approval( + reason=rule.reason or f"Approval required at {checkpoint}", + severity=rule.severity, + timeout_s=rule.timeout_s or engine.task_default_timeout_s, + matching_rule_ids=(rule.id,), + ) + tool_name = f"event:{checkpoint}" + response = await _handle_require_approval( + decision=decision, + tool_name=tool_name, + tool_input=tool_input, + engine=engine, + task_id=task_id, + user_id=user_id, + progress=progress, + ts=ts_module, + approval_source="event", + event_type=event_type, + event_checkpoint=str(checkpoint), + rule_id=rule.id, + ) + allowed = response.get("hookSpecificOutput", {}).get("permissionDecision") == "allow" + reason = response.get("hookSpecificOutput", {}).get("permissionDecisionReason", "") + return EventGateResult(allowed=allowed, reason=reason or "") diff --git a/agent/src/event_governance/writer.py b/agent/src/event_governance/writer.py new file mode 100644 index 00000000..a9c0ea9c --- /dev/null +++ b/agent/src/event_governance/writer.py @@ -0,0 +1,56 @@ +"""Emit unified PolicyDecisionEvent rows to TaskEventsTable.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from event_governance.evaluator import EventRule + + +def _correlation_id(event_type: str, rule_id: str) -> str: + return f"{event_type}:{rule_id}:{uuid.uuid4().hex[:12]}" + + +def policy_decision_metadata( + *, + rule: EventRule, + event_type: str, + metadata: dict[str, Any], + enforce: bool, +) -> dict[str, Any]: + """Build metadata for a top-level ``policy_decision`` event.""" + trigger_milestone = metadata.get("milestone") if event_type == "agent_milestone" else None + checkpoint = metadata.get("checkpoint") + would_block = rule.action == "require_approval" and rule.mode == "enforce" + decision = "require_approval" if rule.action == "require_approval" else "observe" + if rule.action in ("notify", "cancel_task", "inject_nudge", "escalate"): + decision = "observe" + return { + "decision": decision, + "source": "event_rule", + "enforcement_mode": "enforce" if enforce else "observe_only", + "rule_id": rule.id, + "rule_pack_id": rule.rule_pack_id, + "trigger_event_type": event_type, + "trigger_milestone": trigger_milestone, + "checkpoint": checkpoint, + "correlation_id": _correlation_id(event_type, rule.id), + "matching_rule_ids": [rule.id], + "reason": rule.reason or f"Event rule {rule.id} matched on {rule.on}", + "severity": rule.severity, + "timeout_s": rule.timeout_s, + "action": rule.action, + "would_block": would_block, + } + + +def write_policy_decision(progress: Any, *, event_type: str, metadata: dict[str, Any]) -> None: + """Write a top-level policy_decision event via ProgressWriter.""" + if progress is None: + return + if hasattr(progress, "write_policy_decision_event"): + progress.write_policy_decision_event(metadata) + else: + progress._put_event("policy_decision", metadata) diff --git a/agent/src/hooks.py b/agent/src/hooks.py index 6ecf7299..0c7ecc12 100644 --- a/agent/src/hooks.py +++ b/agent/src/hooks.py @@ -360,6 +360,10 @@ async def _handle_require_approval( user_id: str | None, progress: Any, ts: Any, + approval_source: str = "tool", + event_type: str | None = None, + event_checkpoint: str | None = None, + rule_id: str | None = None, ) -> dict: """REQUIRE_APPROVAL branch of ``pre_tool_use_hook``. @@ -468,6 +472,14 @@ async def _handle_require_approval( "user_id": user_id or "", "repo": engine.repo, } + if approval_source != "tool": + row["source"] = approval_source + if event_type: + row["event_type"] = event_type + if event_checkpoint: + row["checkpoint"] = event_checkpoint + if rule_id: + row["rule_id"] = rule_id # Step 6 — bump counters BEFORE the write so cap/rate checks on # subsequent gates reflect the attempt even if the DDB write itself diff --git a/agent/src/models.py b/agent/src/models.py index d7687c4e..7de68f5d 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -240,6 +240,9 @@ class TaskConfig(BaseModel): # orchestrator payload did not include the field (legacy tasks); # PolicyEngine falls back to its own default of 50 in that case. approval_gate_cap: int | None = None + # Event governance (#230): frozen rules from orchestrator payload. + event_rules: list[dict] = Field(default_factory=list) + event_rule_pack_id: str | None = None issue: GitHubIssue | None = None base_branch: str | None = None # Attachments from the orchestrator payload (Phase 3). Validated as diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index a6bb7857..419a4467 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -54,6 +54,67 @@ ) +class EventGovernanceBlocked(Exception): + """Raised when an enforce-mode event rule blocks pipeline progression.""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +def _workflow_ref(config: TaskConfig) -> str | None: + wf = config.resolved_workflow + if isinstance(wf, dict) and wf.get("id"): + return str(wf["id"]) + return None + + +def _run_event_governance( + config: TaskConfig, + progress: _ProgressWriter, + *, + event_type: str, + metadata: dict, + engine=None, +) -> None: + from event_governance.coordinator import evaluate_sync_event + + meta = dict(metadata) + wf = _workflow_ref(config) + if wf: + meta.setdefault("workflow_ref", wf) + result = evaluate_sync_event( + rules_raw=config.event_rules, + event_type=event_type, + metadata=meta, + progress=progress, + rule_pack_id=config.event_rule_pack_id, + config=config, + engine=engine, + task_id=config.task_id, + user_id=config.user_id, + ) + if result is not None and not result.allowed: + raise EventGovernanceBlocked(result.reason) + + +def _emit_checkpoint_and_evaluate( + config: TaskConfig, + progress: _ProgressWriter, + checkpoint: str, + details: str = "", + engine=None, +) -> None: + progress.write_checkpoint(checkpoint, details) + _run_event_governance( + config, + progress, + event_type="agent_milestone", + metadata={"milestone": checkpoint, "checkpoint": checkpoint, "details": details}, + engine=engine, + ) + + def _chain_prior_agent_error(agent_result: AgentResult | None, exc: BaseException) -> str: """Preserve agent-layer failures when a later pipeline stage raises.""" tail = f"{type(exc).__name__}: {exc}" @@ -601,6 +662,8 @@ def run_task( initial_approvals: list[str] | None = None, initial_approval_gate_count: int = 0, approval_gate_cap: int | None = None, + event_rules: list[dict] | None = None, + event_rule_pack_id: str | None = None, channel_source: str = "", channel_metadata: dict[str, str] | None = None, trace: bool = False, @@ -647,6 +710,11 @@ def run_task( attachments=attachments, ) + if event_rules: + config.event_rules = event_rules + if event_rule_pack_id: + config.event_rule_pack_id = event_rule_pack_id + # Inject Cedar policies into config for the PolicyEngine in runner.py if cedar_policies: config.cedar_policies = cedar_policies @@ -830,6 +898,22 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: "repo_setup_complete", f"branch={setup.branch} build_before={setup.build_before}", ) + _run_event_governance( + config, + progress, + event_type="agent_milestone", + metadata={ + "milestone": "repo_setup_complete", + "details": f"branch={setup.branch} build_before={setup.build_before}", + "workflow_ref": _workflow_ref(config), + }, + ) + _emit_checkpoint_and_evaluate( + config, + progress, + "checkpoint:before_execution", + "repo setup complete", + ) system_prompt = build_system_prompt(config, setup, hc, system_prompt_overrides) @@ -1044,6 +1128,12 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: build_passed = verify_build(setup.repo_dir) lint_passed = verify_lint(setup.repo_dir) + _emit_checkpoint_and_evaluate( + config, + progress, + "checkpoint:before_open_pr", + "pre-PR verification complete", + ) pr_url = ensure_pr( config, setup, @@ -1199,6 +1289,22 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: return result_dict + except EventGovernanceBlocked as e: + crash_result = TaskResult( + status="error", + error=f"Event governance denied: {e}", + task_id=config.task_id, + agent_status=agent_result.status if agent_result else "unknown", + ) + task_state.write_terminal(config.task_id, "FAILED", crash_result.model_dump()) + react_task_finished( + config.channel_source, + config.channel_metadata, + success=False, + started_reaction_id=linear_eyes_reaction_id, + ) + return crash_result.model_dump() + except Exception as e: # Ensure the task is marked FAILED in DynamoDB even if the pipeline # crashes before reaching the normal terminal-state write. diff --git a/agent/src/progress_writer.py b/agent/src/progress_writer.py index f7dbea69..60defc32 100644 --- a/agent/src/progress_writer.py +++ b/agent/src/progress_writer.py @@ -663,6 +663,17 @@ def write_agent_milestone(self, milestone: str, details: str = "") -> None: }, ) + def write_checkpoint(self, checkpoint: str, details: str = "") -> None: + """Emit a pipeline-owned governance checkpoint (#230).""" + self._put_event( + "agent_milestone", + { + "milestone": checkpoint, + "checkpoint": checkpoint, + "details": self._preview(details), + }, + ) + def write_agent_cost_update( self, cost_usd: float | None, @@ -1019,3 +1030,7 @@ def write_policy_decision_cached( "original_decision_ts": original_decision_ts, }, ) + + def write_policy_decision_event(self, metadata: dict) -> None: + """Emit unified ``policy_decision`` event (issue #230).""" + self._put_event("policy_decision", metadata) diff --git a/agent/src/server.py b/agent/src/server.py index a7ae76ce..bd44d1a4 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -395,6 +395,8 @@ def _run_task_background( initial_approvals: list[str] | None = None, initial_approval_gate_count: int = 0, approval_gate_cap: int | None = None, + event_rules: list[dict] | None = None, + event_rule_pack_id: str | None = None, channel_source: str = "", channel_metadata: dict[str, str] | None = None, trace: bool = False, @@ -481,6 +483,8 @@ def _run_task_background( initial_approvals=initial_approvals, initial_approval_gate_count=initial_approval_gate_count, approval_gate_cap=approval_gate_cap, + event_rules=event_rules, + event_rule_pack_id=event_rule_pack_id, channel_source=channel_source, channel_metadata=channel_metadata, trace=trace, @@ -571,6 +575,11 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: task_id=inp.get("task_id"), ) approval_gate_cap = None + event_rules = inp.get("event_rules") or [] + if not isinstance(event_rules, list): + event_rules = [] + raw_event_rule_pack_id = inp.get("event_rule_pack_id") + event_rule_pack_id = str(raw_event_rule_pack_id) if raw_event_rule_pack_id else None channel_source = inp.get("channel_source", "") or "" channel_metadata = inp.get("channel_metadata") or {} attachments = inp.get("attachments") or [] @@ -641,6 +650,8 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "initial_approvals": initial_approvals, "initial_approval_gate_count": initial_approval_gate_count, "approval_gate_cap": approval_gate_cap, + "event_rules": event_rules, + "event_rule_pack_id": event_rule_pack_id, "channel_source": channel_source, "channel_metadata": channel_metadata, "trace": trace, diff --git a/agent/src/task_state.py b/agent/src/task_state.py index fbb95c4d..54dc3bb5 100644 --- a/agent/src/task_state.py +++ b/agent/src/task_state.py @@ -7,7 +7,7 @@ import os import time -from typing import Any, TypedDict +from typing import Any, NotRequired, TypedDict from shell import log, log_error_cw @@ -38,6 +38,10 @@ class ApprovalRow(TypedDict): ttl: int user_id: str repo: str + source: NotRequired[str] + event_type: NotRequired[str] + checkpoint: NotRequired[str] + rule_id: NotRequired[str] _table = None diff --git a/agent/tests/test_event_governance_evaluator.py b/agent/tests/test_event_governance_evaluator.py new file mode 100644 index 00000000..370753c8 --- /dev/null +++ b/agent/tests/test_event_governance_evaluator.py @@ -0,0 +1,74 @@ +"""Tests for event governance rule matching.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from event_governance.evaluator import match_rules, parse_rules + +FIXTURES = Path(__file__).resolve().parents[1] / "event-rules" / "fixtures" + + +def _load_fixture(name: str) -> dict: + with (FIXTURES / f"{name}.json").open(encoding="utf-8") as fh: + return json.load(fh) + + +def test_observe_repo_setup_fixture(): + fx = _load_fixture("observe-repo-setup") + rules = parse_rules(fx["rules"]) + matched = match_rules( + rules, + event_type=fx["event"]["event_type"], + metadata=fx["event"]["metadata"], + evaluation="sync", + ) + assert [r.id for r in matched] == fx["expected_matches"] + + +def test_async_notify_pr_fixture(): + fx = _load_fixture("async-notify-pr") + rules = parse_rules(fx["rules"]) + matched = match_rules( + rules, + event_type=fx["event"]["event_type"], + metadata=fx["event"]["metadata"], + evaluation="async", + ) + assert [r.id for r in matched] == fx["expected_matches"] + + +def test_aggregate_cost_cancel_fixture(): + fx = _load_fixture("aggregate-cost-cancel") + rules = parse_rules(fx["rules"]) + matched = match_rules( + rules, + event_type=fx["event"]["event_type"], + metadata=fx["event"]["metadata"], + evaluation="async", + aggregate_state=fx.get("aggregate_state"), + ) + assert [r.id for r in matched] == fx["expected_matches"] + + +def test_when_fields_no_match(): + rules = parse_rules( + [ + { + "id": "x", + "on": "repo_setup_complete", + "when": {"fields": {"workflow_ref": "other"}}, + "action": "observe_only", + "mode": "observe_only", + "evaluation": "sync", + } + ] + ) + matched = match_rules( + rules, + event_type="agent_milestone", + metadata={"milestone": "repo_setup_complete", "workflow_ref": "coding/new-task-v1"}, + evaluation="sync", + ) + assert matched == [] diff --git a/agent/tests/test_event_governance_gate.py b/agent/tests/test_event_governance_gate.py new file mode 100644 index 00000000..bd7dfcff --- /dev/null +++ b/agent/tests/test_event_governance_gate.py @@ -0,0 +1,65 @@ +"""Tests for event governance sync gate (issue #230 Phase 2).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from event_governance.coordinator import evaluate_sync_event +from event_governance.gate import EventGateResult + + +def test_observe_only_writes_policy_decision_without_blocking() -> None: + progress = MagicMock() + rules = [ + { + "id": "observe-plan", + "on": "checkpoint:before_execution", + "action": "require_approval", + "mode": "observe_only", + "evaluation": "sync", + "reason": "dry run", + }, + ] + meta = { + "checkpoint": "checkpoint:before_execution", + "milestone": "checkpoint:before_execution", + } + result = evaluate_sync_event( + rules_raw=rules, + event_type="agent_milestone", + metadata=meta, + progress=progress, + ) + assert result is None + progress.write_policy_decision_event.assert_called_once() + + +def test_enforce_blocks_when_gate_denies() -> None: + progress = MagicMock() + rules = [ + { + "id": "enforce-plan", + "on": "checkpoint:before_execution", + "action": "require_approval", + "mode": "enforce", + "evaluation": "sync", + "reason": "blocked", + }, + ] + with patch( + "event_governance.coordinator.gate_on_event_async", + new_callable=AsyncMock, + return_value=EventGateResult(allowed=False, reason="denied"), + ): + result = evaluate_sync_event( + rules_raw=rules, + event_type="agent_milestone", + metadata={"checkpoint": "checkpoint:before_execution"}, + progress=progress, + config=MagicMock(task_id="t1", user_id="u1"), + engine=MagicMock(), + task_id="t1", + user_id="u1", + ) + assert result is not None + assert result.allowed is False diff --git a/agent/tests/test_event_rules_parity.py b/agent/tests/test_event_rules_parity.py new file mode 100644 index 00000000..e10310c9 --- /dev/null +++ b/agent/tests/test_event_rules_parity.py @@ -0,0 +1,32 @@ +"""Cross-language parity for event rule fixtures (issue #230).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from event_governance.evaluator import match_rules, parse_rules + +FIXTURES = Path(__file__).resolve().parents[1] / "event-rules" / "fixtures" + + +def _fixture_paths() -> list[Path]: + return sorted(FIXTURES.glob("*.json")) + + +@pytest.mark.parametrize("fixture_path", _fixture_paths(), ids=lambda p: p.stem) +def test_event_rules_parity(fixture_path: Path) -> None: + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + rules = parse_rules(fixture["rules"]) + matched = [ + r.id + for r in match_rules( + rules, + event_type=fixture["event"]["event_type"], + metadata=fixture["event"].get("metadata", {}), + aggregate_state=fixture.get("aggregate_state"), + ) + ] + assert sorted(matched) == sorted(fixture["expected_matches"]) diff --git a/cdk/src/constructs/blueprint.ts b/cdk/src/constructs/blueprint.ts index 5ac64ac1..b3cd9228 100644 --- a/cdk/src/constructs/blueprint.ts +++ b/cdk/src/constructs/blueprint.ts @@ -45,6 +45,28 @@ const REPO_CONFIG_CR_TIMEOUT_MINUTES = 5; /** TTL (days) applied to a RepoConfig row on blueprint delete before cleanup. */ const REMOVED_REPO_TTL_DAYS = 30; +/** Inline event governance rule (issue #230). Schema: agent/event-rules/schema.json */ +export interface BlueprintEventRule { + readonly id: string; + readonly on: string; + readonly when?: { + readonly fields?: Readonly>; + readonly aggregate?: { readonly cost_usd_gte?: number; readonly turn_count_gte?: number }; + }; + readonly action: string; + readonly mode: string; + readonly evaluation: string; + readonly reason?: string; + readonly severity?: string; + readonly timeout_s?: number; + readonly notify_channels?: readonly string[]; +} + +export interface BlueprintEventRulePackRef { + readonly id: string; + readonly version: string; +} + /** * Properties for the Blueprint construct. */ @@ -135,6 +157,16 @@ export interface BlueprintProps { * back to the platform default of 50 defined in the handler layer. */ readonly approvalGateCap?: number; + + /** + * Event governance rules (issue #230). Inline until registry pack is pinned. + */ + readonly eventRules?: readonly BlueprintEventRule[]; + + /** + * Registry pin for an event-rule-pack asset (Phase 3). + */ + readonly eventRulePack?: BlueprintEventRulePackRef; }; /** @@ -183,12 +215,21 @@ export class Blueprint extends Construct { */ public readonly approvalGateCap?: number; + /** + * Event governance rules from security.eventRules. + */ + public readonly eventRules: readonly BlueprintEventRule[]; + + public readonly eventRulePack?: BlueprintEventRulePackRef; + constructor(scope: Construct, id: string, props: BlueprintProps) { super(scope, id); this.egressAllowlist = [...(props.networking?.egressAllowlist ?? [])]; this.cedarPolicies = [...(props.security?.cedarPolicies ?? [])]; this.approvalGateCap = props.security?.approvalGateCap; + this.eventRules = [...(props.security?.eventRules ?? [])]; + this.eventRulePack = props.security?.eventRulePack; // Chunk 7c: emit a synth-time info annotation when the blueprint did // not configure an override so operators see a signal that this repo @@ -248,6 +289,13 @@ export class Blueprint extends Construct { if (this.approvalGateCap !== undefined) { item.approval_gate_cap = { N: String(this.approvalGateCap) }; } + if (this.eventRules.length > 0) { + item.event_rules = { S: JSON.stringify(this.eventRules) }; + } + if (this.eventRulePack) { + item.event_rule_pack_id = { S: this.eventRulePack.id }; + item.event_rule_pack_version = { S: this.eventRulePack.version }; + } new cr.AwsCustomResource(this, 'RepoConfigCR', { timeout: Duration.minutes(REPO_CONFIG_CR_TIMEOUT_MINUTES), @@ -320,6 +368,11 @@ export class Blueprint extends Construct { if (this.egressAllowlist.length > 0) fields.push(', #egress_allowlist = :egress_allowlist'); if (this.cedarPolicies.length > 0) fields.push(', #cedar_policies = :cedar_policies'); if (this.approvalGateCap !== undefined) fields.push(', #approval_gate_cap = :approval_gate_cap'); + if (this.eventRules.length > 0) fields.push(', #event_rules = :event_rules'); + if (this.eventRulePack) { + fields.push(', #event_rule_pack_id = :event_rule_pack_id'); + fields.push(', #event_rule_pack_version = :event_rule_pack_version'); + } return fields.join(''); } @@ -335,6 +388,11 @@ export class Blueprint extends Construct { if (this.egressAllowlist.length > 0) names['#egress_allowlist'] = 'egress_allowlist'; if (this.cedarPolicies.length > 0) names['#cedar_policies'] = 'cedar_policies'; if (this.approvalGateCap !== undefined) names['#approval_gate_cap'] = 'approval_gate_cap'; + if (this.eventRules.length > 0) names['#event_rules'] = 'event_rules'; + if (this.eventRulePack) { + names['#event_rule_pack_id'] = 'event_rule_pack_id'; + names['#event_rule_pack_version'] = 'event_rule_pack_version'; + } return names; } @@ -350,6 +408,11 @@ export class Blueprint extends Construct { if (this.egressAllowlist.length > 0) values[':egress_allowlist'] = { L: this.egressAllowlist.map(d => ({ S: d })) }; if (this.cedarPolicies.length > 0) values[':cedar_policies'] = { L: this.cedarPolicies.map(p => ({ S: p })) }; if (this.approvalGateCap !== undefined) values[':approval_gate_cap'] = { N: String(this.approvalGateCap) }; + if (this.eventRules.length > 0) values[':event_rules'] = { S: JSON.stringify(this.eventRules) }; + if (this.eventRulePack) { + values[':event_rule_pack_id'] = { S: this.eventRulePack.id }; + values[':event_rule_pack_version'] = { S: this.eventRulePack.version }; + } return values; } } diff --git a/cdk/src/constructs/fanout-consumer.ts b/cdk/src/constructs/fanout-consumer.ts index bbb5566e..4af21650 100644 --- a/cdk/src/constructs/fanout-consumer.ts +++ b/cdk/src/constructs/fanout-consumer.ts @@ -106,6 +106,17 @@ export interface FanOutConsumerProps { */ readonly linearOauthSecretArnPattern?: string; + /** + * TaskApprovalsTable — async event governance may create post-hoc + * approval rows (issue #230). + */ + readonly taskApprovalsTable?: dynamodb.ITable; + + /** + * TaskNudgesTable — async ``inject_nudge`` actions write steering rows. + */ + readonly taskNudgesTable?: dynamodb.ITable; + /** * Maximum batch size delivered to the Lambda per invocation. * @@ -184,6 +195,17 @@ export class FanOutConsumer extends Construct { props.taskTable.grantReadWriteData(this.fn); this.fn.addEnvironment('TASK_TABLE_NAME', props.taskTable.tableName); } + props.taskEventsTable.grantReadWriteData(this.fn); + this.fn.addEnvironment('TASK_EVENTS_TABLE_NAME', props.taskEventsTable.tableName); + this.fn.addEnvironment('EVENT_GOVERNANCE_ENABLED', 'true'); + if (props.taskApprovalsTable) { + props.taskApprovalsTable.grantReadWriteData(this.fn); + this.fn.addEnvironment('TASK_APPROVALS_TABLE_NAME', props.taskApprovalsTable.tableName); + } + if (props.taskNudgesTable) { + props.taskNudgesTable.grantWriteData(this.fn); + this.fn.addEnvironment('NUDGES_TABLE_NAME', props.taskNudgesTable.tableName); + } if (props.repoTable) { props.repoTable.grantReadData(this.fn); this.fn.addEnvironment('REPO_TABLE_NAME', props.repoTable.tableName); diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index d2f67993..c4bc5e3e 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -980,7 +980,27 @@ export class TaskApi extends Construct { new apigw.LambdaIntegration(getPoliciesFn), cognitoAuthOptions, ); - allFunctions.push(getPoliciesFn); + const eventRules = repoById.addResource('event-rules'); + const getEventRulesFn = new lambda.NodejsFunction(this, 'GetEventRulesFn', { + entry: path.join(handlersDir, 'get-event-rules.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: { + ...approvalEnv, + REPO_TABLE_NAME: props.repoTable.tableName, + }, + bundling: commonBundling, + timeout: Duration.seconds(API_HANDLER_TIMEOUT_SECONDS), + }); + props.taskApprovalsTable.grantReadWriteData(getEventRulesFn); + props.repoTable.grantReadData(getEventRulesFn); + eventRules.addMethod( + 'GET', + new apigw.LambdaIntegration(getEventRulesFn), + cognitoAuthOptions, + ); + allFunctions.push(getPoliciesFn, getEventRulesFn); } } diff --git a/cdk/src/constructs/task-approvals-table.ts b/cdk/src/constructs/task-approvals-table.ts index 20bb4e6e..6942e4c9 100644 --- a/cdk/src/constructs/task-approvals-table.ts +++ b/cdk/src/constructs/task-approvals-table.ts @@ -154,6 +154,10 @@ export class TaskApprovalsTable extends Construct { // backfill). Chunks that extend TaskApprovalsTable should // audit this list before shipping. 'matching_rule_ids', + 'source', + 'event_type', + 'checkpoint', + 'rule_id', ], }); } diff --git a/cdk/src/handlers/fanout-task-events.ts b/cdk/src/handlers/fanout-task-events.ts index 461e5998..ae34c00c 100644 --- a/cdk/src/handlers/fanout-task-events.ts +++ b/cdk/src/handlers/fanout-task-events.ts @@ -47,6 +47,7 @@ import type { } from 'aws-lambda'; import { clearTokenCache, resolveGitHubToken } from './shared/context-hydration'; import { classifyError } from './shared/error-classifier'; +import { evaluateAsyncEventRules, isRetryableInfraError, loadTaskForGovernance } from './shared/event-governance-async'; import { renderCommentBody, upsertTaskComment } from './shared/github-comment'; import { postIssueComment } from './shared/linear-feedback'; import { logger } from './shared/logger'; @@ -250,6 +251,7 @@ function unionSubscribedTypes(overrides?: TaskNotificationsConfig): ReadonlySet< * to each channel per stream poll (~1 s). A future follow-up can * promote this to a DDB-backed rate limiter if needed. */ const MAX_EVENTS_PER_TASK_PER_INVOCATION = 20; +const EVENT_GOVERNANCE_ENABLED = process.env.EVENT_GOVERNANCE_ENABLED !== 'false'; export interface FanOutEvent { readonly task_id: string; @@ -1150,16 +1152,15 @@ export interface RouteOutcome { export async function routeEvent( ev: FanOutEvent, overrides?: TaskNotificationsConfig, + options?: { readonly forcedChannels?: readonly NotificationChannel[] }, ): Promise { const attempted: NotificationChannel[] = []; const tasks: Promise[] = []; - // Match against the effective type so ``agent_milestone`` carriers - // (``pr_created``, ``nudge_acknowledged``, …) reach the channels - // subscribed to those milestone names. const effective = effectiveEventType(ev); + const forced = new Set(options?.forcedChannels ?? []); for (const ch of CHANNELS) { const filter = resolveChannelFilter(ch, overrides); - if (!filter.has(effective)) continue; + if (!filter.has(effective) && !forced.has(ch)) continue; attempted.push(ch); tasks.push(DISPATCHERS[ch](ev)); } @@ -1263,7 +1264,39 @@ export const handler = async ( dropped++; continue; } - if (!shouldFanOut(ev, overrides)) { + + let governanceForcedChannels: NotificationChannel[] = []; + let governanceForceFanOut = false; + + try { + if (EVENT_GOVERNANCE_ENABLED) { + const taskForGov = await loadTaskForGovernance(ev.task_id); + // Aggregate high-water tracking is owned by evaluateAsyncEventRules, + // which persists and resolves the durable marks (#230); the handler + // just forwards the event and task record. + const govResult = await evaluateAsyncEventRules( + { ...ev, metadata: ev.metadata ?? {} }, + { task: taskForGov }, + ); + governanceForcedChannels = govResult.notifyChannels.filter( + (ch): ch is NotificationChannel => ch in DISPATCHERS, + ); + governanceForceFanOut = govResult.forceFanOut || governanceForcedChannels.length > 0; + } + } catch (govErr) { + // Infra errors (DDB throttle / 5xx) must retry the record — otherwise an + // enforce cancel_task / require_approval is silently skipped and the + // cost/turn high-water mark under-counts (#230). Benign failures degrade + // to fanout-only, as before, to preserve poison-pill isolation. + if (isRetryableInfraError(govErr)) throw govErr; + logger.warn('[fanout] event governance evaluation failed — continuing fanout', { + task_id: ev.task_id, + event_id: ev.event_id, + error: govErr instanceof Error ? govErr.message : String(govErr), + }); + } + + if (!shouldFanOut(ev, overrides) && !governanceForceFanOut) { dropped++; continue; } @@ -1283,7 +1316,9 @@ export const handler = async ( } perTaskCounts.set(ev.task_id, seen + 1); - const outcome = await routeEvent(ev, overrides); + const outcome = await routeEvent(ev, overrides, { + forcedChannels: governanceForcedChannels, + }); if (outcome.dispatched.length > 0) dispatched++; // Per-channel infra rejections (DDB throttle, Secrets Manager // 5xx, transient Slack 5xx) escalate to the partial-batch retry diff --git a/cdk/src/handlers/get-event-rules.ts b/cdk/src/handlers/get-event-rules.ts new file mode 100644 index 00000000..126389dd --- /dev/null +++ b/cdk/src/handlers/get-event-rules.ts @@ -0,0 +1,171 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { listBuiltinEventRulePacks, resolveEventRules, UnknownEventRulePackError } from './shared/event-rule-pack-resolver'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { formatMinuteBucket, RATE_LIMIT_ROW_TTL_SECONDS } from './shared/rate-limit'; +import { checkRepoOnboarded, loadRepoConfig } from './shared/repo-config'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { EventRule, GetEventRulesResponse } from './shared/types'; +import { getWorkflowDescriptor } from './shared/workflows'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TASK_APPROVALS_TABLE_NAME = process.env.TASK_APPROVALS_TABLE_NAME; +const POLICIES_RATE_LIMIT_PER_MINUTE = Number(process.env.POLICIES_RATE_LIMIT_PER_MINUTE ?? '30'); + +const CACHE_TTL_MINUTES = 5; +const CACHE_TTL_MS = CACHE_TTL_MINUTES * 60 * 1000; +// Best-effort, per-Lambda-instance cache — not shared across concurrent +// instances, and a blueprint edit takes up to CACHE_TTL_MINUTES to surface. +// Harmless at current scale (read-only rule listing); revisit if staleness or +// cross-instance consistency ever matters. +const cache = new Map(); + +function summarizeRule(rule: EventRule): { + readonly rule_id: string; + readonly on: string; + readonly action: string; + readonly mode: string; + readonly evaluation: string; + readonly reason?: string; +} { + return { + rule_id: rule.id, + on: rule.on, + action: rule.action, + mode: rule.mode, + evaluation: rule.evaluation, + ...(rule.reason && { reason: rule.reason }), + }; +} + +/** + * GET /v1/repos/{repo_id}/event-rules — list resolved event governance rules. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const rawRepoId = event.pathParameters?.repo_id; + if (!rawRepoId) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Missing repo_id path parameter.', requestId); + } + const repoId = decodeURIComponent(rawRepoId); + + if (TASK_APPROVALS_TABLE_NAME) { + const nowEpoch = Math.floor(Date.now() / 1000); + const minuteBucket = formatMinuteBucket(new Date()); + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_APPROVALS_TABLE_NAME, + Key: { + task_id: `RATE#${userId}#EVENT_RULES`, + request_id: `MINUTE#${minuteBucket}`, + }, + UpdateExpression: 'ADD #count :one SET #ttl = :ttl', + ConditionExpression: 'attribute_not_exists(#count) OR #count < :max', + ExpressionAttributeNames: { '#count': 'count', '#ttl': 'ttl' }, + ExpressionAttributeValues: { + ':one': 1, + ':max': POLICIES_RATE_LIMIT_PER_MINUTE, + ':ttl': nowEpoch + RATE_LIMIT_ROW_TTL_SECONDS, + }, + })); + } catch (err: unknown) { + const name = (err as { name?: string })?.name; + if (name === 'ConditionalCheckFailedException') { + return errorResponse( + 429, + ErrorCode.RATE_LIMIT_EXCEEDED, + `Rate limit exceeded: at most ${POLICIES_RATE_LIMIT_PER_MINUTE} event-rules queries per minute.`, + requestId, + ); + } + throw err; + } + } + + // Cache key includes workflow_ref: resolved rules depend on the workflow's + // eventRulePack, so keying on repo alone would serve one workflow's rules + // for another within the TTL window (#230). + const workflowRef = event.queryStringParameters?.workflow_ref; + const cacheKey = `${repoId}\n${workflowRef ?? ''}`; + const cached = cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return successResponse(200, cached.response, requestId); + } + + const onboardingResult = await checkRepoOnboarded(repoId); + if (!onboardingResult.onboarded) { + return errorResponse( + 422, + ErrorCode.REPO_NOT_ONBOARDED, + `Repository '${repoId}' is not onboarded. Register it with a Blueprint before querying event rules.`, + requestId, + ); + } + + const repoConfig = await loadRepoConfig(repoId); + const workflow = workflowRef ? getWorkflowDescriptor(workflowRef) : undefined; + const packRef = repoConfig?.event_rule_pack ?? workflow?.eventRulePack; + let resolved: EventRule[]; + try { + resolved = resolveEventRules({ + inlineRules: repoConfig?.event_rules, + packRef, + }); + } catch (err) { + if (err instanceof UnknownEventRulePackError) { + return errorResponse(422, ErrorCode.VALIDATION_ERROR, err.message, requestId); + } + throw err; + } + + const response: GetEventRulesResponse = { + repo_id: repoId, + event_rule_pack: packRef, + rules: resolved.map(summarizeRule), + registry_packs: listBuiltinEventRulePacks(), + }; + + cache.set(cacheKey, { response, expiresAt: Date.now() + CACHE_TTL_MS }); + return successResponse(200, response, requestId); + } catch (err) { + logger.error('get-event-rules failed', { + error: err instanceof Error ? err.message : String(err), + request_id: requestId, + }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} + +/** Test seam — clear the in-memory response cache between cases. */ +export function _resetCacheForTests(): void { + cache.clear(); +} diff --git a/cdk/src/handlers/get-pending.ts b/cdk/src/handlers/get-pending.ts index 42563bed..570b3308 100644 --- a/cdk/src/handlers/get-pending.ts +++ b/cdk/src/handlers/get-pending.ts @@ -128,6 +128,10 @@ export async function handler(event: APIGatewayProxyEvent): Promise 0 ? parts.join(', plus ') : 'a task specification'; } +/** 503 for a blueprint/workflow that pins an unresolvable event-rule-pack — + * a platform misconfiguration, same class as an out-of-bounds approval cap. */ +function unknownPackResponse(repo: string | undefined, err: UnknownEventRulePackError, requestId: string): APIGatewayProxyResult { + logger.error('Blueprint misconfiguration — unknown event-rule-pack pin', { + repo, + pack_id: err.packRef.id, + pack_version: err.packRef.version, + request_id: requestId, + }); + return errorResponse( + 503, + ErrorCode.SERVICE_UNAVAILABLE, + `Blueprint misconfiguration: event-rule-pack '${err.packRef.id}@${err.packRef.version}' ` + + `for '${repo}' does not resolve. Ask the platform admin to re-deploy the blueprint with a valid pack pin.`, + requestId, + ); +} + /** * Core task creation logic shared by the Cognito create-task handler * and the webhook create-task handler. @@ -156,6 +175,9 @@ export async function createTaskCore( // shift the cap beneath a running task). A repo-less task takes the // platform default cap. let resolvedApprovalGateCap: number = APPROVAL_GATE_CAP_DEFAULT; + let resolvedEventRules: ReturnType = []; + let eventRulePackId: string | undefined; + let eventRulePackVersion: string | undefined; // Whether the cap came from a blueprint (vs the platform default) — surfaced // in the "Task created" log. A repo-less submission has no blueprint, so it // stays false. @@ -208,6 +230,37 @@ export async function createTaskCore( resolvedApprovalGateCap = blueprintCap; capFromBlueprint = true; } + const packRef = repoConfig?.event_rule_pack ?? workflow.eventRulePack; + try { + resolvedEventRules = resolveEventRules({ + inlineRules: repoConfig?.event_rules, + packRef, + }); + } catch (err) { + if (err instanceof UnknownEventRulePackError) { + return unknownPackResponse(body.repo, err, requestId); + } + throw err; + } + if (packRef) { + eventRulePackId = packRef.id; + eventRulePackVersion = packRef.version; + } + } else { + try { + resolvedEventRules = resolveEventRules({ + packRef: workflow.eventRulePack, + }); + } catch (err) { + if (err instanceof UnknownEventRulePackError) { + return unknownPackResponse(body.repo, err, requestId); + } + throw err; + } + if (workflow.eventRulePack) { + eventRulePackId = workflow.eventRulePack.id; + eventRulePackVersion = workflow.eventRulePack.version; + } } // A pr_* workflow resolves an existing PR rather than opening a new branch. @@ -647,6 +700,9 @@ export async function createTaskCore( // platform default of 50. Persisted so a container restart or a // mid-task blueprint edit cannot shift the cap beneath the task. approval_gate_cap: resolvedApprovalGateCap, + ...(resolvedEventRules.length > 0 && { event_rules: resolvedEventRules }), + ...(eventRulePackId && { event_rule_pack_id: eventRulePackId }), + ...(eventRulePackVersion && { event_rule_pack_version: eventRulePackVersion }), }; // 6. Write task record diff --git a/cdk/src/handlers/shared/event-governance-approval.ts b/cdk/src/handlers/shared/event-governance-approval.ts new file mode 100644 index 00000000..111ca8e8 --- /dev/null +++ b/cdk/src/handlers/shared/event-governance-approval.ts @@ -0,0 +1,160 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Async event-sourced approval rows (issue #230 Phase 2). + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + PutCommand, + TransactWriteCommand, +} from '@aws-sdk/lib-dynamodb'; +import { ulid } from 'ulid'; +import type { EventRule } from './event-governance-types'; +import { logger } from './logger'; +import { isRetryableInfraError } from './retryable-error'; +import type { TaskRecord } from './types'; +import { TaskStatus } from '../../constructs/task-status'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TASK_TABLE = process.env.TASK_TABLE_NAME; +const APPROVALS_TABLE = process.env.TASK_APPROVALS_TABLE_NAME; + +const DEFAULT_APPROVAL_TIMEOUT_S = 3600; +const PREVIEW_MAX_LEN = 500; + +function previewText(metadata: Readonly>, checkpoint: string): string { + const prUrl = metadata.pr_url; + if (typeof prUrl === 'string' && prUrl.length > 0) { + return `PR already exists: ${prUrl}`.slice(0, PREVIEW_MAX_LEN); + } + return `Event gate at ${checkpoint}`.slice(0, PREVIEW_MAX_LEN); +} + +/** + * Create a PENDING approval for an async event rule. When the task is + * RUNNING, atomically transitions to AWAITING_APPROVAL. Post-hoc gates + * (e.g. after ``pr_created``) only persist the approval row. + */ +export async function createAsyncEventApproval(options: { + readonly task: TaskRecord; + readonly rule: EventRule; + readonly eventType: string; + readonly metadata: Readonly>; + readonly requestId?: string; +}): Promise { + if (!APPROVALS_TABLE || !TASK_TABLE) return undefined; + + const requestId = options.requestId ?? ulid(); + const checkpoint = typeof options.metadata.checkpoint === 'string' + ? options.metadata.checkpoint + : options.rule.on; + const reason = options.rule.reason + ?? (options.rule.on === 'pr_created' + ? 'Approval required after PR was opened (PR already exists).' + : `Event rule ${options.rule.id} requires approval`); + const timeoutS = options.rule.timeout_s ?? DEFAULT_APPROVAL_TIMEOUT_S; + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + timeoutS * 1000).toISOString(); + + const approvalItem: Record = { + task_id: options.task.task_id, + request_id: requestId, + user_id: options.task.user_id, + status: 'PENDING', + tool_name: `event:${checkpoint}`, + tool_input_preview: previewText(options.metadata, checkpoint), + severity: options.rule.severity ?? 'medium', + reason, + created_at: now, + timeout_s: timeoutS, + expires_at: expiresAt, + matching_rule_ids: [options.rule.id], + source: 'event', + event_type: options.eventType, + checkpoint, + rule_id: options.rule.id, + ...(options.rule.rule_pack_id && { rule_pack_id: options.rule.rule_pack_id }), + }; + + try { + if (options.task.status === TaskStatus.RUNNING) { + await ddb.send(new TransactWriteCommand({ + TransactItems: [ + { + Put: { + TableName: APPROVALS_TABLE, + Item: approvalItem, + ConditionExpression: 'attribute_not_exists(request_id)', + }, + }, + { + Update: { + TableName: TASK_TABLE, + Key: { task_id: options.task.task_id }, + UpdateExpression: 'SET #status = :awaiting, awaiting_approval_request_id = :rid, updated_at = :now', + ConditionExpression: '#status = :running', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { + ':awaiting': TaskStatus.AWAITING_APPROVAL, + ':running': TaskStatus.RUNNING, + ':rid': requestId, + ':now': now, + }, + }, + }, + ], + })); + } else { + await ddb.send(new PutCommand({ + TableName: APPROVALS_TABLE, + Item: approvalItem, + ConditionExpression: 'attribute_not_exists(request_id)', + })); + } + return requestId; + } catch (err) { + const name = (err as { name?: string })?.name; + // Benign: the task already left RUNNING (TransactWrite condition), or this + // request_id already exists (a settled duplicate). The gate did not need to + // fire again — swallow without a retry. + if (name === 'ConditionalCheckFailedException' || name === 'TransactionCanceledException') { + logger.info('[event-governance] async require_approval already settled', { + task_id: options.task.task_id, + rule_id: options.rule.id, + status: options.task.status, + }); + return undefined; + } + // A throttle/5xx must retry the record — otherwise the approval row is never + // written, the task never transitions to AWAITING_APPROVAL, and the gate is + // silently skipped while the caller reports it fired (#230). + if (isRetryableInfraError(err)) throw err; + logger.error('[event-governance] async require_approval failed (non-retryable) — enforcement gap', { + task_id: options.task.task_id, + rule_id: options.rule.id, + status: options.task.status, + error_id: 'EVENT_GOV_APPROVAL_FAILED', + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} diff --git a/cdk/src/handlers/shared/event-governance-async.ts b/cdk/src/handlers/shared/event-governance-async.ts new file mode 100644 index 00000000..e915d3d6 --- /dev/null +++ b/cdk/src/handlers/shared/event-governance-async.ts @@ -0,0 +1,502 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Async event governance actions invoked from FanOutConsumer (issue #230). + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { ulid } from 'ulid'; +import { createAsyncEventApproval } from './event-governance-approval'; +import { + buildPolicyDecisionMetadata, + matchEventRules, + parseEventRules, + type AggregateState, + type EvaluableEvent, +} from './event-rule-evaluator'; +import { logger } from './logger'; +import { coerceNumericOrNull } from './numeric'; +import { isRetryableInfraError } from './retryable-error'; +import { NUDGE_MAX_MESSAGE_LENGTH, type EventRule, type TaskRecord } from './types'; +import { computeTtlEpoch } from './validation'; +import { TaskStatus, TERMINAL_STATUSES } from '../../constructs/task-status'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TASK_TABLE = process.env.TASK_TABLE_NAME; +const EVENTS_TABLE = process.env.TASK_EVENTS_TABLE_NAME; +const NUDGES_TABLE = process.env.NUDGES_TABLE_NAME; +const TASK_RETENTION_DAYS = Number(process.env.TASK_RETENTION_DAYS ?? '90'); +const NUDGE_RETENTION_DAYS = 30; +const NUDGE_RETENTION_SECONDS = NUDGE_RETENTION_DAYS * 86400; + +/** Test seam — disable async governance without mocking an extra DDB GetItem. */ +let eventGovernanceEnabled = true; + +export function _setEventGovernanceEnabled(enabled: boolean): void { + eventGovernanceEnabled = enabled; +} + +export function isEventGovernanceEnabled(): boolean { + return eventGovernanceEnabled; +} + +export interface AsyncGovernanceContext { + readonly task?: TaskRecord; + readonly aggregateState?: { cumulative_cost_usd?: number; turn_count?: number }; +} + +export interface AsyncGovernanceResult { + readonly notifyChannels: string[]; + readonly forceFanOut: boolean; +} + +// Re-exported so existing importers/tests keep resolving it from this module; +// the canonical definition lives in ./retryable-error so the approval path can +// consume it without a circular import back into this module. +export { isRetryableInfraError }; + +export async function loadTaskForGovernance(taskId: string): Promise { + if (!TASK_TABLE) return undefined; + try { + const result = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + return result.Item as TaskRecord | undefined; + } catch (err) { + // A throttle/5xx here would silently disable every ``&& task`` enforce + // action (cancel_task, require_approval). Rethrow infra errors so the + // fanout flags the record for retry; swallow only benign failures. See #230. + if (isRetryableInfraError(err)) throw err; + logger.warn('[event-governance] task load failed (non-retryable) — continuing', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } +} + +async function emitPolicyDecision( + taskId: string, + metadata: Record, +): Promise { + if (!EVENTS_TABLE) return; + const now = new Date().toISOString(); + await ddb.send(new PutCommand({ + TableName: EVENTS_TABLE, + Item: { + task_id: taskId, + event_id: ulid(), + event_type: 'policy_decision', + timestamp: now, + ttl: computeTtlEpoch(TASK_RETENTION_DAYS), + metadata, + }, + })); +} + +async function cancelTaskByRule(taskId: string, rule: EventRule, reason: string): Promise { + if (!TASK_TABLE) return; + const now = new Date().toISOString(); + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET #status = :cancelled, updated_at = :now, completed_at = :now, error_message = :reason, status_created_at = :sca', + ConditionExpression: 'attribute_exists(task_id) AND NOT #status IN (:s1, :s2, :s3, :s4)', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { + ':cancelled': TaskStatus.CANCELLED, + ':now': now, + ':reason': reason, + ':sca': `${TaskStatus.CANCELLED}#${now}`, + ':s1': TaskStatus.COMPLETED, + ':s2': TaskStatus.FAILED, + ':s3': TaskStatus.CANCELLED, + ':s4': TaskStatus.TIMED_OUT, + }, + })); + if (EVENTS_TABLE) { + await ddb.send(new PutCommand({ + TableName: EVENTS_TABLE, + Item: { + task_id: taskId, + event_id: ulid(), + event_type: 'task_cancelled', + timestamp: now, + ttl: computeTtlEpoch(TASK_RETENTION_DAYS), + metadata: { source: 'event_rule', rule_id: rule.id, reason }, + }, + })); + } + } catch (err) { + const name = (err as { name?: string })?.name; + // Benign: the task raced to a terminal status between loadTaskForGovernance + // and this Update, so the climb-only ConditionExpression rejected it. The + // task is already done/cancelled — nothing to enforce. Swallow (mirrors the + // approval path) rather than parking the record in batchItemFailures forever. + if (name === 'ConditionalCheckFailedException') { + logger.info('[event-governance] cancel_task skipped — task already terminal', { + task_id: taskId, + rule_id: rule.id, + }); + return; + } + // cancel_task IS the enforcement action ("cancel if cost exceeds $25"). A + // throttle/5xx here must retry the record, not silently let the task run on + // past its ceiling. + if (isRetryableInfraError(err)) throw err; + logger.error('[event-governance] cancel_task failed (non-retryable) — enforcement gap', { + task_id: taskId, + rule_id: rule.id, + error_id: 'EVENT_GOV_CANCEL_FAILED', + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +async function injectNudgeByRule( + task: TaskRecord, + rule: EventRule, + metadata: Readonly>, +): Promise { + if (!NUDGES_TABLE) return; + const message = rule.reason + ?? `Event rule ${rule.id} injected steering at ${rule.on}`; + const nudgeId = ulid(); + const now = new Date().toISOString(); + const nowEpoch = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: NUDGES_TABLE, + Item: { + task_id: task.task_id, + nudge_id: nudgeId, + user_id: task.user_id, + message: message.slice(0, NUDGE_MAX_MESSAGE_LENGTH), + created_at: now, + consumed: false, + ttl: nowEpoch + NUDGE_RETENTION_SECONDS, + source: 'event_rule', + rule_id: rule.id, + event_type: metadata.milestone ?? rule.on, + }, + ConditionExpression: 'attribute_not_exists(nudge_id)', + })); + } catch (err) { + // The nudge row is keyed by a fresh ULID, so the attribute_not_exists guard + // only ever fires on a genuine ULID collision (never) — any CCF here is + // effectively an infra fault. Rethrow retryable errors so steering isn't + // silently dropped on a transient blip. + if (isRetryableInfraError(err)) throw err; + logger.warn('[event-governance] inject_nudge skipped (non-retryable)', { + task_id: task.task_id, + rule_id: rule.id, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function correlationId(event: EvaluableEvent & { event_id?: string }, rule: EventRule): string { + const base = event.event_id ?? `${event.event_type}:${Date.now()}`; + return `${base}:${rule.id}`; +} + +/** First finite value among the metadata aliases, coerced from string/number. */ +function readAggregate(meta: Readonly>, aliases: readonly string[]): number | undefined { + for (const key of aliases) { + const n = coerceNumericOrNull(meta[key] as number | string | null | undefined, { field: key }, logger); + if (n !== null) return n; + } + return undefined; +} + +/** + * Extract the incoming cumulative cost/turn from an event's metadata (#230). + * Cost is sourced from ``agent_cost_update`` (SDK session ``total_cost_usd``), + * turns from ``agent_turn`` (the ``result.turns`` AssistantMessage counter). + * Each aggregate has exactly ONE producing event so the two turn counters the + * SDK exposes (``result.turns`` vs ``num_turns``) never mix into one mark. + */ +function incomingAggregate(event: EvaluableEvent): { cost?: number; turns?: number } { + const meta = event.metadata; + const out: { cost?: number; turns?: number } = {}; + if (event.event_type === 'agent_cost_update') { + out.cost = readAggregate(meta, ['cumulative_cost_usd', 'cost_usd']); + } + if (event.event_type === 'agent_turn') { + out.turns = readAggregate(meta, ['turn_count', 'turn']); + } + return out; +} + +/** + * Bump the durable high-water marks on the TaskRecord and return the effective + * aggregate state for rule evaluation (#230). + * + * The mark is a monotonic MAX (``if_not_exists`` + a ``<`` ConditionExpression), + * NOT a cross-session sum. Two reasons max is the correct semantic here: + * 1. Idempotency. The source is a DynamoDB stream with at-least-once delivery + * and partial-batch retries; a SUM would double-count on every replay, + * whereas re-applying a max is a no-op. A ceiling rule must not fire early + * because a batch was retried. + * 2. A normal task is single-session (one ClaudeSDKClient per run_agent), so + * the SDK ``total_cost_usd`` / ``result.turns`` are already the true task + * totals; max just makes them durable across a container restart, where + * the per-session counter resets to 0. + * ponytail: on a restart whose *second* session out-costs the first only in + * aggregate (each session individually cheaper than the first), max under-counts + * the true lifetime sum. The upgrade is per-session delta accounting keyed by + * session_id — deferred until multi-session tasks are common, because it trades + * the stream's free idempotency for a dedup table. + * + * Cost also seeds from the authoritative ``TaskRecord.cost_usd`` (written by the + * agent's task_state terminal path) so a cost event that arrives without cost + * metadata, or the first evaluation of an already-costed task, still counts. + * On any DDB error we fall back to the resolved value so evaluation proceeds. + */ +async function persistAndResolveAggregate( + taskId: string, + event: EvaluableEvent, + task: TaskRecord | undefined, +): Promise { + const incoming = incomingAggregate(event); + // Authoritative task cost (may be a string from the DDB doc-client) is a floor + // for the cost mark — bridges the gap the removed inline fallback used to fill. + const taskCost = coerceNumericOrNull( + task?.cost_usd as number | string | null | undefined, + { field: 'cost_usd', task_id: taskId }, + logger, + ); + if (incoming.cost === undefined && incoming.turns === undefined) { + // Not a cost/turn event — use whatever is already persisted, seeded by + // task.cost_usd so a ceiling can still trip on a non-cost event. + if (!task) return undefined; + const cost = Math.max(task.gov_cumulative_cost_usd ?? 0, taskCost ?? 0) || undefined; + return { cumulative_cost_usd: cost ?? task.gov_cumulative_cost_usd, turn_count: task.gov_cumulative_turn_count }; + } + + const priorCost = Math.max(task?.gov_cumulative_cost_usd ?? 0, taskCost ?? 0); + const priorTurns = task?.gov_cumulative_turn_count ?? 0; + const resolvedCost = incoming.cost !== undefined ? Math.max(priorCost, incoming.cost) : priorCost || task?.gov_cumulative_cost_usd; + const resolved: AggregateState = { + cumulative_cost_usd: resolvedCost, + turn_count: incoming.turns !== undefined ? Math.max(priorTurns, incoming.turns) : task?.gov_cumulative_turn_count, + }; + + if (!TASK_TABLE) return resolved; + + const sets: string[] = []; + const conds: string[] = []; + const values: Record = {}; + if (incoming.cost !== undefined) { + // Persist the resolved value (max of prior mark, task.cost_usd seed, and the + // incoming reading) so the seed is durable, not just used for this eval. + sets.push('gov_cumulative_cost_usd = :c'); + conds.push('(attribute_not_exists(gov_cumulative_cost_usd) OR gov_cumulative_cost_usd < :c)'); + values[':c'] = resolvedCost ?? incoming.cost; + } + if (incoming.turns !== undefined) { + sets.push('gov_cumulative_turn_count = :t'); + conds.push('(attribute_not_exists(gov_cumulative_turn_count) OR gov_cumulative_turn_count < :t)'); + values[':t'] = incoming.turns; + } + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: `SET ${sets.join(', ')}`, + // Only write when at least one mark climbs; a no-op (retry with equal or + // lower value) fails the condition and is silently skipped. + ConditionExpression: `attribute_exists(task_id) AND (${conds.join(' OR ')})`, + ExpressionAttributeValues: values, + })); + } catch (err) { + const name = (err as { name?: string })?.name; + if (name !== 'ConditionalCheckFailedException') { + // Rethrow throttles/5xx so the record is retried — otherwise the mark + // fails to advance and a cost ceiling under-counts with no signal. + if (isRetryableInfraError(err)) throw err; + logger.warn('[event-governance] aggregate high-water update failed', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return resolved; +} + +function idempotencyEventId(taskId: string, ruleId: string, corr: string): string { + return `gov-idem#${taskId}#${ruleId}#${corr}`; +} + +/** Durable idempotency via TaskEventsTable marker row. */ +async function claimIdempotency(taskId: string, ruleId: string, corr: string): Promise { + if (!EVENTS_TABLE) return true; + const eventId = idempotencyEventId(taskId, ruleId, corr); + try { + await ddb.send(new PutCommand({ + TableName: EVENTS_TABLE, + Item: { + task_id: taskId, + event_id: eventId, + event_type: 'governance_idempotency', + timestamp: new Date().toISOString(), + ttl: computeTtlEpoch(TASK_RETENTION_DAYS), + metadata: { rule_id: ruleId, correlation_id: corr }, + }, + ConditionExpression: 'attribute_not_exists(event_id)', + })); + return true; + } catch (err) { + const name = (err as { name?: string })?.name; + // Marker already exists — a genuine duplicate delivery. Skip the rule. + if (name === 'ConditionalCheckFailedException') return false; + // A throttle/5xx on the claim itself must retry the whole record rather than + // proceed: proceeding on an unclaimed marker risks double-firing a notify / + // escalate (which are not conditional-safe) on the eventual retry (#230). + if (isRetryableInfraError(err)) throw err; + logger.warn('[event-governance] idempotency claim failed (non-retryable) — proceeding', { + task_id: taskId, + rule_id: ruleId, + error: err instanceof Error ? err.message : String(err), + }); + return true; + } +} + +/** Delete the idempotency marker so a rethrown (retried) record can re-claim + * and re-run the enforcement action. Best-effort: if this delete is itself + * throttled the marker survives and the retry is a no-op (the action is lost), + * but that is strictly better than double-firing, and the enforce actions + * (cancel/approval) are themselves conditional so re-running is safe. */ +async function releaseIdempotency(taskId: string, ruleId: string, corr: string): Promise { + if (!EVENTS_TABLE) return; + try { + await ddb.send(new DeleteCommand({ + TableName: EVENTS_TABLE, + Key: { task_id: taskId, event_id: idempotencyEventId(taskId, ruleId, corr) }, + })); + } catch (err) { + logger.warn('[event-governance] idempotency release failed', { + task_id: taskId, + rule_id: ruleId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** Channels for a notify/escalate rule; falls back to a default so a rule with + * no ``notify_channels`` still delivers rather than silently doing nothing. */ +function resolveChannels(rule: EventRule, fallback: string[]): string[] { + const base = rule.notify_channels ?? []; + return base.length > 0 ? [...base] : fallback; +} + +/** + * Evaluate async event rules for one stream record. + */ +export async function evaluateAsyncEventRules( + event: EvaluableEvent & { task_id: string; event_id: string }, + ctx: AsyncGovernanceContext, +): Promise { + const task = ctx.task; + const rules = parseEventRules(task?.event_rules); + if (rules.length === 0) return { notifyChannels: [], forceFanOut: false }; + + // Resolve aggregates against the durable high-water mark so ceiling rules + // survive the per-session SDK cost/turn reset (#230). ``ctx.aggregateState`` + // (the caller's live reading) is the fallback when no durable value applies. + const aggregateState = await persistAndResolveAggregate(event.task_id, event, task) + ?? ctx.aggregateState; + + const matched = matchEventRules(rules, event, { + evaluation: 'async', + aggregateState, + }); + const notifyChannels: string[] = []; + let forceFanOut = false; + + for (const rule of matched) { + const corr = correlationId(event, rule); + const claimed = await claimIdempotency(event.task_id, rule.id, corr); + if (!claimed) continue; + + const enforce = rule.mode === 'enforce'; + const meta = buildPolicyDecisionMetadata(rule, event, enforce, corr); + + // The audit emit AND the enforce action share one release-guarded try: the + // idempotency marker is already claimed, so if EITHER throws a retryable + // error we must release the marker before the record retries — otherwise the + // retry re-claims false and the rule is skipped entirely, silently dropping + // the audit record (observe_only) or the enforcement action (#230). + try { + await emitPolicyDecision(event.task_id, { ...meta, correlation_id: corr }); + + // observe_only records the policy_decision above but must NOT fire the + // action — the "would have fired" contract (design §8). + if (enforce) { + if (rule.action === 'notify') { + notifyChannels.push(...resolveChannels(rule, ['slack'])); + forceFanOut = true; + } + + if (rule.action === 'escalate') { + notifyChannels.push(...resolveChannels(rule, ['email', 'slack'])); + forceFanOut = true; + } + + if (rule.action === 'inject_nudge' && task) { + await injectNudgeByRule(task, rule, event.metadata); + } + + if (rule.action === 'require_approval' && task) { + await createAsyncEventApproval({ + task, + rule, + eventType: event.event_type, + metadata: event.metadata, + }); + forceFanOut = true; + } + + if (rule.action === 'cancel_task' && task && !TERMINAL_STATUSES.includes(task.status)) { + await cancelTaskByRule( + event.task_id, + rule, + rule.reason ?? `Event rule ${rule.id} triggered cancel_task`, + ); + } + } + } catch (err) { + await releaseIdempotency(event.task_id, rule.id, corr); + throw err; + } + } + + return { notifyChannels: [...new Set(notifyChannels)], forceFanOut }; +} + +/** Test helper — retained for module tests (no-op; idempotency is durable). */ +export function _resetGovernanceIdempotencyCache(): void { + // no-op — durable idempotency uses TaskEventsTable markers +} diff --git a/cdk/src/handlers/shared/event-governance-types.ts b/cdk/src/handlers/shared/event-governance-types.ts new file mode 100644 index 00000000..943d4589 --- /dev/null +++ b/cdk/src/handlers/shared/event-governance-types.ts @@ -0,0 +1,81 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Event governance types (issue #230). Keep in sync with cli/src/types.ts. + */ + +export type EventRuleAction = + | 'require_approval' + | 'notify' + | 'escalate' + | 'cancel_task' + | 'inject_nudge' + | 'observe_only'; + +export type EventRuleMode = 'observe_only' | 'enforce'; +export type EventRuleEvaluation = 'sync' | 'async'; +export type ApprovalSource = 'tool' | 'event'; + +export type NotificationChannelWithWebhook = 'slack' | 'email' | 'github' | 'linear' | 'webhook'; + +export interface EventRuleWhen { + readonly fields?: Readonly>; + readonly aggregate?: { + readonly cost_usd_gte?: number; + readonly turn_count_gte?: number; + }; +} + +export interface EventRule { + readonly id: string; + readonly on: string; + readonly when?: EventRuleWhen; + readonly action: EventRuleAction; + readonly mode: EventRuleMode; + readonly evaluation: EventRuleEvaluation; + readonly reason?: string; + readonly severity?: 'low' | 'medium' | 'high'; + readonly timeout_s?: number; + readonly notify_channels?: readonly NotificationChannelWithWebhook[]; + readonly rule_pack_id?: string; +} + +export interface EventRulePackRef { + readonly id: string; + readonly version: string; +} + +export interface PolicyDecisionMetadata { + readonly decision: 'allow' | 'deny' | 'require_approval' | 'observe'; + readonly source: 'cedar_tool' | 'event_rule' | 'submission'; + readonly enforcement_mode: EventRuleMode; + readonly rule_id?: string; + readonly rule_pack_id?: string; + readonly trigger_event_type?: string; + readonly trigger_milestone?: string; + readonly checkpoint?: string; + readonly correlation_id?: string; + readonly matching_rule_ids?: readonly string[]; + readonly reason?: string; + readonly severity?: string; + readonly timeout_s?: number; + readonly action?: string; + readonly would_block?: boolean; +} diff --git a/cdk/src/handlers/shared/event-rule-evaluator.ts b/cdk/src/handlers/shared/event-rule-evaluator.ts new file mode 100644 index 00000000..6cb4ab6b --- /dev/null +++ b/cdk/src/handlers/shared/event-rule-evaluator.ts @@ -0,0 +1,189 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Declarative event rule matching — parity with agent/src/event_governance/evaluator.py. + */ + +import type { EventRule, EventRuleEvaluation, PolicyDecisionMetadata } from './event-governance-types'; +import { logger } from './logger'; +import { coerceNumericOrNull } from './numeric'; + +export interface EvaluableEvent { + readonly event_type: string; + readonly metadata: Readonly>; + readonly event_id?: string; +} + +export interface AggregateState { + readonly cumulative_cost_usd?: number; + readonly turn_count?: number; +} + +function eventName(event: EvaluableEvent): string { + if (event.event_type === 'agent_milestone') { + const milestone = event.metadata.milestone; + if (typeof milestone === 'string') return milestone; + } + const checkpoint = event.metadata.checkpoint; + if (typeof checkpoint === 'string') return checkpoint; + return event.event_type; +} + +function fieldsMatch(when: EventRule['when'], metadata: Readonly>): boolean { + const expected = when?.fields; + if (!expected) return true; + for (const [key, want] of Object.entries(expected)) { + if (metadata[key] !== want) return false; + } + return true; +} + +/** + * Canonical metadata field names for aggregate values, each with its accepted + * aliases (base field a producer emits ↔ cumulative field the rule reads). The + * evaluator normalizes here so producers emit ONE name and rules match without + * every producer having to echo a duplicate alias. See #230. + */ +const AGGREGATE_FIELDS = { + cost: ['cumulative_cost_usd', 'cost_usd'], + turns: ['turn_count', 'turn'], +} as const; + +/** First finite value among the metadata aliases, coerced from string/number. */ +function readAggregate( + metadata: Readonly>, + aliases: readonly string[], +): number | null { + for (const key of aliases) { + const n = coerceNumericOrNull(metadata[key] as number | string | null | undefined, { field: key }, logger); + if (n !== null) return n; + } + return null; +} + +function aggregateMatch( + when: EventRule['when'], + metadata: Readonly>, + aggregateState?: AggregateState, +): boolean { + const agg = when?.aggregate; + if (!agg) return true; + if (agg.cost_usd_gte !== undefined) { + const cumulative = aggregateState?.cumulative_cost_usd ?? readAggregate(metadata, AGGREGATE_FIELDS.cost); + if (cumulative === null || cumulative === undefined) return false; + if (cumulative < agg.cost_usd_gte) return false; + } + if (agg.turn_count_gte !== undefined) { + const turns = aggregateState?.turn_count ?? readAggregate(metadata, AGGREGATE_FIELDS.turns); + if (turns === null || turns === undefined) return false; + if (turns < agg.turn_count_gte) return false; + } + return true; +} + +export function matchEventRules( + rules: readonly EventRule[], + event: EvaluableEvent, + options?: { + readonly evaluation?: EventRuleEvaluation; + readonly aggregateState?: AggregateState; + }, +): EventRule[] { + const name = eventName(event); + return rules.filter((rule) => { + if (rule.on !== name) return false; + if (options?.evaluation !== undefined && rule.evaluation !== options.evaluation) return false; + if (!fieldsMatch(rule.when, event.metadata)) return false; + if (!aggregateMatch(rule.when, event.metadata, options?.aggregateState)) return false; + return true; + }); +} + +export function buildPolicyDecisionMetadata( + rule: EventRule, + event: EvaluableEvent, + enforce: boolean, + correlationIdOverride?: string, +): PolicyDecisionMetadata { + const triggerMilestone = event.event_type === 'agent_milestone' + ? String(event.metadata.milestone ?? '') + : undefined; + const checkpoint = typeof event.metadata.checkpoint === 'string' + ? event.metadata.checkpoint + : undefined; + const wouldBlock = rule.action === 'require_approval' && rule.mode === 'enforce'; + let decision: PolicyDecisionMetadata['decision'] = 'observe'; + if (rule.action === 'require_approval') decision = 'require_approval'; + return { + decision, + source: 'event_rule', + enforcement_mode: enforce ? 'enforce' : 'observe_only', + rule_id: rule.id, + rule_pack_id: rule.rule_pack_id, + trigger_event_type: event.event_type, + trigger_milestone: triggerMilestone, + checkpoint, + correlation_id: correlationIdOverride + ?? `${event.event_type}:${rule.id}:${event.event_id ?? Date.now()}`, + matching_rule_ids: [rule.id], + reason: rule.reason ?? `Event rule ${rule.id} matched on ${rule.on}`, + severity: rule.severity, + timeout_s: rule.timeout_s, + action: rule.action, + would_block: wouldBlock, + }; +} + +export function parseEventRules(raw: unknown): EventRule[] { + if (!Array.isArray(raw)) return []; + const out: EventRule[] = []; + for (const [index, item] of raw.entries()) { + if (!item || typeof item !== 'object') { + logger.warn('[event-governance] dropped malformed rule — not an object', { index }); + continue; + } + const r = item as Record; + if (typeof r.id !== 'string' || typeof r.on !== 'string') { + // Fail loud: a dropped ceiling/approval rule means zero enforcement with no + // other signal. Mirror the fail-loud stance the pack resolver takes (#230). + logger.warn('[event-governance] dropped malformed rule — missing id/on', { + index, + id: typeof r.id === 'string' ? r.id : undefined, + }); + continue; + } + out.push({ + id: r.id, + on: r.on, + when: r.when as EventRule['when'], + action: (r.action as EventRule['action']) ?? 'observe_only', + mode: (r.mode as EventRule['mode']) ?? 'observe_only', + evaluation: (r.evaluation as EventRule['evaluation']) ?? 'sync', + reason: typeof r.reason === 'string' ? r.reason : undefined, + severity: r.severity as EventRule['severity'], + timeout_s: typeof r.timeout_s === 'number' ? r.timeout_s : undefined, + notify_channels: Array.isArray(r.notify_channels) + ? r.notify_channels as EventRule['notify_channels'] + : undefined, + rule_pack_id: typeof r.rule_pack_id === 'string' ? r.rule_pack_id : undefined, + }); + } + return out; +} diff --git a/cdk/src/handlers/shared/event-rule-pack-resolver.ts b/cdk/src/handlers/shared/event-rule-pack-resolver.ts new file mode 100644 index 00000000..b2066d75 --- /dev/null +++ b/cdk/src/handlers/shared/event-rule-pack-resolver.ts @@ -0,0 +1,111 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Resolve event rules from inline config and registry pack pins (#230 Phase 3). + * + * Packs are authored alongside workflows under ``agent/event-rules/packs/*.json`` + * and bundled into the resolver at build time. Until the agent asset registry + * (#246) ships, this file IS the registry; the resolver interface matches the + * future ``RegistryService.resolve('event-rule-pack')`` so the swap is local. + */ + +import type { EventRule, EventRulePackRef } from './event-governance-types'; +import { parseEventRules } from './event-rule-evaluator'; +import platformDefaultPack from '../../../../agent/event-rules/packs/platform-default-v1.json'; + +interface PackFile { + readonly pack_id?: string; + readonly pack_version?: string; + readonly rules: unknown; +} + +function loadPackFile(file: PackFile): { id: string; version: string; rules: readonly EventRule[] } { + const id = file.pack_id ?? 'unknown'; + const version = file.pack_version ?? '0.0.0'; + return { id, version, rules: parseEventRules(file.rules) }; +} + +/** Builtin registry — swap for #246 RegistryService when available. */ +const BUILTIN_PACKS: Readonly>>> = (() => { + const loaded = loadPackFile(platformDefaultPack as PackFile); + const byVersion: Record = { + [loaded.version]: loaded.rules, + }; + return { + [loaded.id]: byVersion, + // Alias for blueprint pins using the filename stem. + 'platform-default-v1': byVersion, + }; +})(); + +export function listBuiltinEventRulePacks(): ReadonlyArray<{ + readonly id: string; + readonly version: string; + readonly rule_count: number; +}> { + const loaded = loadPackFile(platformDefaultPack as PackFile); + return [{ + id: loaded.id, + version: loaded.version, + rule_count: loaded.rules.length, + }]; +} + +/** Thrown when a blueprint/workflow pins an event-rule-pack that does not + * resolve. Fail loud rather than silently applying zero governance rules — + * a mis-pinned pack must surface at submit / via the API, not leave a task + * running with a ceiling rule that was never loaded (#230). */ +export class UnknownEventRulePackError extends Error { + constructor(public readonly packRef: EventRulePackRef) { + super(`Unknown event-rule-pack pin: ${packRef.id}@${packRef.version}. ` + + 'No such pack/version is bundled; check the Blueprint\'s security.eventRulePack.'); + this.name = 'UnknownEventRulePackError'; + } +} + +export function resolveEventRules(options: { + readonly inlineRules?: readonly EventRule[]; + readonly packRef?: EventRulePackRef; +}): EventRule[] { + const inline = options.inlineRules ?? []; + if (!options.packRef) return [...inline]; + + const versionMap = BUILTIN_PACKS[options.packRef.id]; + const packRules = versionMap?.[options.packRef.version]; + if (packRules === undefined) { + throw new UnknownEventRulePackError(options.packRef); + } + if (inline.length === 0) { + return packRules.map(r => ({ + ...r, + rule_pack_id: options.packRef?.id, + })); + } + + const inlineById = new Map(inline.map(r => [r.id, r] as const)); + const merged = packRules.map(r => { + const override = inlineById.get(r.id); + return override ?? { ...r, rule_pack_id: options.packRef?.id }; + }); + for (const rule of inline) { + if (!packRules.some(r => r.id === rule.id)) merged.push(rule); + } + return merged; +} diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 7308c453..fe0316e0 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -279,6 +279,8 @@ export async function loadBlueprintConfig(task: TaskRecord): Promise 0 && { event_rules: task.event_rules }), + ...(task.event_rule_pack_id && { event_rule_pack_id: task.event_rule_pack_id }), + ...(task.event_rule_pack_version && { event_rule_pack_version: task.event_rule_pack_version }), prompt_version: promptVersion, ...(MEMORY_ID && { memory_id: MEMORY_ID }), hydrated_context: hydratedContext, diff --git a/cdk/src/handlers/shared/repo-config.ts b/cdk/src/handlers/shared/repo-config.ts index 1753d568..a1a6800c 100644 --- a/cdk/src/handlers/shared/repo-config.ts +++ b/cdk/src/handlers/shared/repo-config.ts @@ -19,6 +19,8 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import type { EventRule, EventRulePackRef } from './event-governance-types'; +import { parseEventRules } from './event-rule-evaluator'; import { logger } from './logger'; /** @@ -52,6 +54,8 @@ export interface RepoConfig { * path falls back to the platform default of 50. */ readonly approval_gate_cap?: number; + readonly event_rules?: readonly EventRule[]; + readonly event_rule_pack?: EventRulePackRef; } /** @@ -77,10 +81,38 @@ export interface BlueprintConfig { * field is informational for the runtime path. */ readonly approval_gate_cap?: number; + readonly event_rules?: readonly EventRule[]; + readonly event_rule_pack?: EventRulePackRef; } const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +function normalizeRepoConfig(raw: Record): RepoConfig { + const config = raw as unknown as RepoConfig; + let eventRules = config.event_rules; + if (typeof raw.event_rules === 'string') { + try { + eventRules = parseEventRules(JSON.parse(raw.event_rules)); + } catch { + eventRules = []; + } + } else if (Array.isArray(raw.event_rules)) { + eventRules = parseEventRules(raw.event_rules); + } + let eventRulePack = config.event_rule_pack; + if (raw.event_rule_pack_id && raw.event_rule_pack_version) { + eventRulePack = { + id: String(raw.event_rule_pack_id), + version: String(raw.event_rule_pack_version), + }; + } + return { + ...config, + ...(eventRules !== undefined && { event_rules: eventRules }), + ...(eventRulePack !== undefined && { event_rule_pack: eventRulePack }), + }; +} + /** * Combined result of a single RepoTable GetItem used by the submit * path. ``onboarded`` mirrors the old ``checkRepoOnboarded`` contract @@ -136,7 +168,7 @@ export async function lookupRepo(repo: string): Promise { return { onboarded: false, config: null }; } - return { onboarded: true, config }; + return { onboarded: true, config: normalizeRepoConfig(result.Item as Record) }; } catch (err) { logger.error('Failed to look up repo config', { repo, diff --git a/cdk/src/handlers/shared/retryable-error.ts b/cdk/src/handlers/shared/retryable-error.ts new file mode 100644 index 00000000..a7755705 --- /dev/null +++ b/cdk/src/handlers/shared/retryable-error.ts @@ -0,0 +1,41 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** Names of AWS SDK errors that a stream-record retry can plausibly clear + * (throttles, transient 5xx). Distinguishes "retry the record" from a + * poison-pill that would stall the shard forever. */ +const RETRYABLE_AWS_ERROR = /Throttling|ProvisionedThroughputExceeded|RequestLimitExceeded|ServiceUnavailable|InternalServerError|InternalFailure|TransactionInProgress|5\d\d/i; + +/** + * True when an error is a transient infra fault (throttle / 5xx) that a stream + * retry can clear — as opposed to a benign conditional failure or a + * deterministic client error that would just fail again. Governance + * enforcement paths rethrow these so the FanOut record enters + * ``batchItemFailures`` instead of silently skipping a cancel/approval (#230). + */ +export function isRetryableInfraError(err: unknown): boolean { + if (err && typeof err === 'object') { + const e = err as { name?: string; $retryable?: unknown; $metadata?: { httpStatusCode?: number } }; + if (e.$retryable) return true; + const status = e.$metadata?.httpStatusCode; + if (typeof status === 'number' && status >= 500) return true; + if (e.name && RETRYABLE_AWS_ERROR.test(e.name)) return true; + } + return false; +} diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index 468a7c20..1c82b318 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -18,6 +18,11 @@ */ import { classifyError, type ErrorClassification } from './error-classifier'; +import type { + ApprovalSource, + EventRule, + EventRulePackRef, +} from './event-governance-types'; import { logger } from './logger'; import { coerceNumericOrNull } from './numeric'; import type { ComputeType } from './repo-config'; @@ -27,6 +32,17 @@ import type { ComputeType } from './repo-config'; import sharedConstants from '../../../../contracts/constants.json'; import type { TaskStatusType } from '../../constructs/task-status'; +export type { + ApprovalSource, + EventRule, + EventRuleAction, + EventRuleEvaluation, + EventRuleMode, + EventRulePackRef, + EventRuleWhen, + PolicyDecisionMetadata, +} from './event-governance-types'; + /** * Re-export of {@link TaskStatusType} so the CDK↔CLI type-sync drift * check sees a single declaration site for the API status union. The @@ -105,6 +121,26 @@ export interface TaskRecord { readonly started_at?: string; readonly completed_at?: string; readonly cost_usd?: number; + /** + * Durable high-water marks for event-governance aggregate rules (#230). + * The FanOut stream consumer bumps these monotonically as ``agent_cost_update`` + * / ``agent_turn`` events flow, so ``cost_usd_gte`` / ``turn_count_gte`` rules + * survive container restarts (the per-session SDK total resets; these do not). + * + * NOT a second source of truth for task cost: ``cost_usd`` remains the + * authoritative final figure (written by the agent's task_state terminal + * path). ``gov_cumulative_cost_usd`` is a governance-owned monotonic ceiling + * that is SEEDED from ``cost_usd`` (Math.max), so it is always ≥ the + * authoritative value and the two never contradict — one is the final total, + * the other the running max used only for ceiling evaluation. + * ponytail: monotonic max of session totals, not a cross-session SUM — a + * multi-session task's ceiling reflects its largest single session. Sum + * accounting (per-session delta keyed by session_id) is the upgrade if + * PR-fix retries must accrue; deferred because it trades the DynamoDB + * stream's free idempotency for a dedup table. + */ + readonly gov_cumulative_cost_usd?: number; + readonly gov_cumulative_turn_count?: number; readonly duration_s?: number; readonly build_passed?: boolean; /** Whether the post-run lint gate passed (#515). Written with `build_passed` @@ -215,6 +251,13 @@ export interface TaskRecord { * atomically on resume (§10.2, §9). */ readonly awaiting_approval_request_id?: string; + /** + * Event governance (#230): rules frozen at submit-time from blueprint + * inline config and/or resolved registry pack. + */ + readonly event_rules?: readonly EventRule[]; + readonly event_rule_pack_id?: string; + readonly event_rule_pack_version?: string; } /** Per-channel override for one notification channel. See @@ -968,6 +1011,12 @@ interface ApprovalRecordBase { readonly ttl: number; readonly user_id: string; readonly repo: string; + /** Event governance (#230): ``tool`` (default) or ``event``. */ + readonly source?: ApprovalSource; + readonly event_type?: string; + readonly checkpoint?: string; + readonly rule_pack_id?: string; + readonly rule_id?: string; } /** PENDING approval row — no decision recorded yet. */ @@ -1039,6 +1088,10 @@ export interface PendingApprovalSummary { * so `bgagent pending` can show _why_ a gate fired without the user * spelunking TaskEventsTable. Empty array on pre-Cedar-HITL rows. */ readonly matching_rule_ids: readonly string[]; + readonly source?: ApprovalSource; + readonly event_type?: string; + readonly checkpoint?: string; + readonly rule_id?: string; } /** @@ -1121,6 +1174,28 @@ export interface GetPoliciesResponse { }; } +/** Summary row for GET /v1/repos/{repo_id}/event-rules (issue #230). */ +export interface EventRuleSummary { + readonly rule_id: string; + readonly on: string; + readonly action: string; + readonly mode: string; + readonly evaluation: string; + readonly reason?: string; +} + +/** GET /v1/repos/{repo_id}/event-rules response body. */ +export interface GetEventRulesResponse { + readonly repo_id: string; + readonly event_rule_pack?: EventRulePackRef; + readonly rules: readonly EventRuleSummary[]; + readonly registry_packs: readonly { + readonly id: string; + readonly version: string; + readonly rule_count: number; + }[]; +} + /** * `GET /v1/pending` response body (§7.7). */ diff --git a/cdk/src/handlers/shared/workflows.ts b/cdk/src/handlers/shared/workflows.ts index 470b81c0..7b466b84 100644 --- a/cdk/src/handlers/shared/workflows.ts +++ b/cdk/src/handlers/shared/workflows.ts @@ -33,7 +33,7 @@ * asserts the ids/versions match the YAML. */ -import { ResolvedWorkflow } from './types'; +import type { EventRulePackRef, ResolvedWorkflow } from './types'; /** The required-input contract a workflow declares (mirrors the YAML). */ export interface WorkflowRequiredInputs { @@ -64,6 +64,8 @@ export interface WorkflowDescriptor { * silent downgrade). Keep in lockstep with the YAML's `agent_config.model.id`. */ readonly modelId?: string; + /** Optional event-rule-pack pin from workflow YAML (issue #230). */ + readonly eventRulePack?: EventRulePackRef; } /** @@ -113,6 +115,7 @@ const DESCRIPTORS: Record = { requiresRepo: true, readOnly: false, requiredInputs: { oneOf: ['issue_number', 'task_description'] }, + eventRulePack: { id: 'platform-default', version: '1.0.0' }, }, 'coding/pr-iteration-v1': { id: 'coding/pr-iteration-v1', diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 4e0612d9..6f4f0d02 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -920,6 +920,8 @@ export class AgentStack extends Stack { new FanOutConsumer(this, 'FanOutConsumer', { taskEventsTable: taskEventsTable.table, taskTable: taskTable.table, + taskApprovalsTable: taskApprovalsTable.table, + taskNudgesTable: taskNudgesTable.table, repoTable: repoTable.table, githubTokenSecret, // Slack bot-token grant is guarded on this prop — pass the diff --git a/cdk/test/constructs/blueprint.test.ts b/cdk/test/constructs/blueprint.test.ts index 57eca509..1615be21 100644 --- a/cdk/test/constructs/blueprint.test.ts +++ b/cdk/test/constructs/blueprint.test.ts @@ -484,6 +484,23 @@ describe('Blueprint construct', () => { expect(infos).toHaveLength(0); }); + test('persists event_rules JSON when security.eventRules is configured', () => { + const rule = { + id: 'plan-review', + on: 'repo_setup_complete', + action: 'require_approval', + mode: 'observe_only', + evaluation: 'sync', + }; + const { template } = createStack({ + security: { eventRules: [rule] }, + }); + const parts = getCreateJoinParts(template); + const serialized = parts.join(''); + expect(serialized).toContain('event_rules'); + expect(serialized).toContain('plan-review'); + }); + test('onUpdate uses DynamoDB updateItem to preserve onboarded_at', () => { const { template } = createStack(); const parts = getUpdateJoinParts(template); diff --git a/cdk/test/handlers/fanout-task-events.test.ts b/cdk/test/handlers/fanout-task-events.test.ts index aee3d515..54df41ae 100644 --- a/cdk/test/handlers/fanout-task-events.test.ts +++ b/cdk/test/handlers/fanout-task-events.test.ts @@ -35,8 +35,11 @@ const mockDdbSend = jest.fn().mockResolvedValue({ Item: undefined }); // that surfaces as ``GetItemCommand is not a constructor``. jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); jest.mock('@aws-sdk/lib-dynamodb', () => ({ - DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + DynamoDBDocumentClient: { + from: jest.fn(() => ({ send: mockDdbSend })), + }, GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), })); @@ -110,6 +113,9 @@ jest.mock('../../src/handlers/shared/linear-feedback', () => ({ process.env.TASK_TABLE_NAME = 'Tasks'; process.env.GITHUB_TOKEN_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:0:secret:platform'; process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; +// Governance adds extra DDB reads/writes per stream record; existing +// dispatcher tests script precise Get/Update sequences and disable it. +process.env.EVENT_GOVERNANCE_ENABLED = 'false'; import { CHANNEL_DEFAULTS, diff --git a/cdk/test/handlers/get-event-rules.test.ts b/cdk/test/handlers/get-event-rules.test.ts new file mode 100644 index 00000000..598bcf1d --- /dev/null +++ b/cdk/test/handlers/get-event-rules.test.ts @@ -0,0 +1,164 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const mockSend = jest.fn(); +const mockLoadRepoConfig = jest.fn(); +const mockCheckRepoOnboarded = jest.fn(); + +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn(() => ({})), +})); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockSend })) }, + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); +jest.mock('../../src/handlers/shared/repo-config', () => ({ + loadRepoConfig: (...args: unknown[]) => mockLoadRepoConfig(...args), + checkRepoOnboarded: (...args: unknown[]) => mockCheckRepoOnboarded(...args), +})); + +let ulidCounter = 0; +jest.mock('ulid', () => ({ ulid: jest.fn(() => `ULID${ulidCounter++}`) })); + +process.env.TASK_APPROVALS_TABLE_NAME = 'Approvals'; +process.env.POLICIES_RATE_LIMIT_PER_MINUTE = '30'; + +import { _resetCacheForTests, handler } from '../../src/handlers/get-event-rules'; + +function makeEvent( + repoId = 'owner%2Frepo', + query: Record | null = null, +): APIGatewayProxyEvent { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'GET', + isBase64Encoded: false, + path: `/v1/repos/${repoId}/event-rules`, + pathParameters: { repo_id: repoId }, + queryStringParameters: query, + multiValueQueryStringParameters: null, + stageVariables: null, + resource: '/repos/{repo_id}/event-rules', + requestContext: { + accountId: '123', + apiId: 'api', + authorizer: { claims: { sub: 'user-alice' } }, + httpMethod: 'GET', + identity: {} as never, + path: `/v1/repos/${repoId}/event-rules`, + protocol: 'HTTP/1.1', + requestId: 'req-1', + requestTime: '', + requestTimeEpoch: 0, + resourceId: '', + resourcePath: '/repos/{repo_id}/event-rules', + stage: 'v1', + }, + } as APIGatewayProxyEvent; +} + +beforeEach(() => { + mockSend.mockReset(); + mockLoadRepoConfig.mockReset(); + mockCheckRepoOnboarded.mockReset(); + mockCheckRepoOnboarded.mockResolvedValue({ onboarded: true }); + ulidCounter = 0; + _resetCacheForTests(); +}); + +describe('get-event-rules', () => { + test('401 when no Cognito claims', async () => { + const event = makeEvent(); + (event.requestContext.authorizer as { claims: Record }).claims = {}; + const res = await handler(event); + expect(res.statusCode).toBe(401); + }); + + test('422 REPO_NOT_ONBOARDED when repo is not in RepoTable', async () => { + mockSend.mockResolvedValue({}); + mockCheckRepoOnboarded.mockResolvedValue({ onboarded: false }); + + const res = await handler(makeEvent('typo%2Frepo')); + expect(res.statusCode).toBe(422); + const body = JSON.parse(res.body); + expect(body.error.code).toBe('REPO_NOT_ONBOARDED'); + expect(mockLoadRepoConfig).not.toHaveBeenCalled(); + }); + + test('200 with platform-default pack when repo has event_rule_pack pin', async () => { + mockSend.mockResolvedValue({}); + mockLoadRepoConfig.mockResolvedValue({ + repo: 'owner/repo', + status: 'active', + onboarded_at: '', + updated_at: '', + event_rule_pack: { id: 'platform-default', version: '1.0.0' }, + }); + + const res = await handler(makeEvent()); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.repo_id).toBe('owner/repo'); + expect(body.data.event_rule_pack).toEqual({ id: 'platform-default', version: '1.0.0' }); + const ruleIds = body.data.rules.map((r: { rule_id: string }) => r.rule_id); + expect(ruleIds).toEqual(expect.arrayContaining(['observe-repo-setup', 'notify-on-pr'])); + expect(body.data.registry_packs.length).toBeGreaterThan(0); + }); + + test('workflow_ref query uses workflow eventRulePack when repo has no pin', async () => { + mockSend.mockResolvedValue({}); + mockLoadRepoConfig.mockResolvedValue({ + repo: 'owner/repo', + status: 'active', + onboarded_at: '', + updated_at: '', + }); + + const res = await handler(makeEvent('owner%2Frepo', { + workflow_ref: 'coding/new-task-v1', + })); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.event_rule_pack).toEqual({ id: 'platform-default', version: '1.0.0' }); + const ruleIds = body.data.rules.map((r: { rule_id: string }) => r.rule_id); + expect(ruleIds).toContain('observe-repo-setup'); + }); + + test('422 VALIDATION_ERROR when repo pins an unknown event-rule-pack', async () => { + mockSend.mockResolvedValue({}); + mockLoadRepoConfig.mockResolvedValue({ + repo: 'owner/repo', + status: 'active', + onboarded_at: '', + updated_at: '', + event_rule_pack: { id: 'does-not-exist', version: '9.9.9' }, + }); + + const res = await handler(makeEvent()); + // Fail loud rather than silently applying zero governance rules (#230). + expect(res.statusCode).toBe(422); + const body = JSON.parse(res.body); + expect(body.error.code).toBe('VALIDATION_ERROR'); + expect(body.error.message).toContain('does-not-exist@9.9.9'); + }); +}); diff --git a/cdk/test/handlers/shared/event-governance-approval.test.ts b/cdk/test/handlers/shared/event-governance-approval.test.ts new file mode 100644 index 00000000..3edfd217 --- /dev/null +++ b/cdk/test/handlers/shared/event-governance-approval.test.ts @@ -0,0 +1,85 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const mockSend = jest.fn(); + +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn(() => ({})), +})); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + TransactWriteCommand: jest.fn((input: unknown) => ({ _type: 'Transact', input })), +})); + +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ-ULID') })); + +process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.TASK_APPROVALS_TABLE_NAME = 'Approvals'; + +import { TaskStatus } from '../../../src/constructs/task-status'; +import { createAsyncEventApproval } from '../../../src/handlers/shared/event-governance-approval'; + +describe('event-governance-approval', () => { + beforeEach(() => { + mockSend.mockReset(); + }); + + const baseTask = { + task_id: 't1', + user_id: 'u1', + status: TaskStatus.RUNNING, + }; + + const baseRule = { + id: 'gate-pr', + on: 'pr_created', + action: 'require_approval' as const, + mode: 'enforce' as const, + evaluation: 'async' as const, + reason: 'Review after PR', + }; + + test('RUNNING task uses TransactWrite to AWAITING_APPROVAL', async () => { + mockSend.mockResolvedValueOnce({}); + const rid = await createAsyncEventApproval({ + task: baseTask as never, + rule: baseRule, + eventType: 'agent_milestone', + metadata: { milestone: 'pr_created', pr_url: 'https://github.com/o/r/pull/1' }, + }); + expect(rid).toBe('REQ-ULID'); + expect(mockSend).toHaveBeenCalledTimes(1); + const cmd = mockSend.mock.calls[0][0] as { _type: string; input: unknown }; + expect(cmd._type).toBe('Transact'); + }); + + test('terminal task only Put approval row (post-hoc gate)', async () => { + mockSend.mockResolvedValueOnce({}); + const rid = await createAsyncEventApproval({ + task: { ...baseTask, status: TaskStatus.COMPLETED } as never, + rule: baseRule, + eventType: 'agent_milestone', + metadata: { milestone: 'pr_created' }, + }); + expect(rid).toBe('REQ-ULID'); + const cmd = mockSend.mock.calls[0][0] as { _type: string }; + expect(cmd._type).toBe('Put'); + }); +}); diff --git a/cdk/test/handlers/shared/event-governance-async-module.test.ts b/cdk/test/handlers/shared/event-governance-async-module.test.ts new file mode 100644 index 00000000..0f829b95 --- /dev/null +++ b/cdk/test/handlers/shared/event-governance-async-module.test.ts @@ -0,0 +1,689 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Coverage-focused tests for event-governance-async helpers. + */ + +const mockSend = jest.fn(); + +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { + from: jest.fn(() => ({ send: mockSend })), + }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), + TransactWriteCommand: jest.fn((input: unknown) => ({ _type: 'TransactWrite', input })), +})); + +describe('event-governance-async module', () => { + beforeEach(() => { + jest.resetModules(); + mockSend.mockReset(); + process.env.TASK_TABLE_NAME = 'Tasks'; + process.env.TASK_EVENTS_TABLE_NAME = 'Events'; + process.env.TASK_APPROVALS_TABLE_NAME = 'Approvals'; + }); + + test('loadTaskForGovernance returns undefined on Get failure', async () => { + mockSend.mockRejectedValueOnce(new Error('boom')); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + const got = await mod.loadTaskForGovernance('t1'); + expect(got).toBeUndefined(); + }); + + test('evaluateAsyncEventRules emits notify channel', async () => { + mockSend + .mockResolvedValueOnce({}) // idempotency claim + .mockResolvedValueOnce({}); // Put policy_decision + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + const result1 = await mod.evaluateAsyncEventRules( + { + task_id: 't1', + event_id: 'e1', + event_type: 'agent_milestone', + metadata: { milestone: 'pr_created' }, + }, + { + task: { + task_id: 't1', + event_rules: [ + { + id: 'notify', + on: 'pr_created', + action: 'notify', + mode: 'enforce', + evaluation: 'async', + notify_channels: ['slack'], + }, + ], + } as any, + }, + ); + expect(result1.notifyChannels).toEqual(['slack']); + expect(result1.forceFanOut).toBe(true); + }); + + test('bumps durable cost high-water mark and evaluates against it', async () => { + // No cumulative value on the event itself; the ceiling must be met from + // the persisted high-water mark alone (the cross-restart case). + mockSend + .mockResolvedValueOnce({}) // high-water UpdateCommand + .mockResolvedValueOnce({}) // idempotency claim + .mockResolvedValueOnce({}) // Put policy_decision + .mockResolvedValueOnce({}) // Update cancel + .mockResolvedValueOnce({}); // Put task_cancelled + const mod = await import('../../../src/handlers/shared/event-governance-async'); + await mod.evaluateAsyncEventRules( + { + task_id: 't3', + event_id: 'e3', + event_type: 'agent_cost_update', + metadata: { cost_usd: 5 }, // this session is cheap... + }, + { + task: { + task_id: 't3', + status: 'RUNNING', + gov_cumulative_cost_usd: 40, // ...but a prior session already crossed + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ); + // First send is the high-water UpdateCommand with a climb-only condition. + const firstCall = mockSend.mock.calls[0][0]; + expect(firstCall._type).toBe('Update'); + expect(firstCall.input.ConditionExpression).toContain('gov_cumulative_cost_usd < :c'); + // The cancel fired — resolved aggregate (max(40, 5)=40) still ≥ 25. + const cancelUpdate = mockSend.mock.calls.find( + (c) => c[0]._type === 'Update' && String(c[0].input.UpdateExpression).includes('#status'), + ); + expect(cancelUpdate).toBeDefined(); + }); + + test('turn_count_gte rule fires on turn_count metadata', async () => { + mockSend.mockResolvedValue({}); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + const result = await mod.evaluateAsyncEventRules( + { + task_id: 't4', + event_id: 'e4', + event_type: 'agent_turn', + metadata: { turn_count: 35 }, + }, + { + task: { + task_id: 't4', + status: 'RUNNING', + event_rules: [ + { + id: 'turns', + on: 'agent_turn', + when: { aggregate: { turn_count_gte: 30 } }, + action: 'escalate', + mode: 'enforce', + evaluation: 'async', + notify_channels: ['slack'], + }, + ], + } as any, + }, + ); + expect(result.notifyChannels).toContain('slack'); + }); + + test('isRetryableInfraError classifies infra vs benign errors', async () => { + const mod = await import('../../../src/handlers/shared/event-governance-async'); + expect(mod.isRetryableInfraError({ $retryable: {} })).toBe(true); + expect(mod.isRetryableInfraError({ $metadata: { httpStatusCode: 503 } })).toBe(true); + expect(mod.isRetryableInfraError({ name: 'ProvisionedThroughputExceededException' })).toBe(true); + expect(mod.isRetryableInfraError({ name: 'ThrottlingException' })).toBe(true); + // Benign / client errors and non-objects must not trigger a retry. + expect(mod.isRetryableInfraError({ name: 'ValidationException' })).toBe(false); + expect(mod.isRetryableInfraError({ $metadata: { httpStatusCode: 400 } })).toBe(false); + expect(mod.isRetryableInfraError(new Error('plain'))).toBe(false); + expect(mod.isRetryableInfraError('boom')).toBe(false); + expect(mod.isRetryableInfraError(undefined)).toBe(false); + }); + + test('loadTaskForGovernance rethrows retryable infra errors', async () => { + const throttle = Object.assign(new Error('rate exceeded'), { + name: 'ProvisionedThroughputExceededException', + }); + mockSend.mockRejectedValueOnce(throttle); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + await expect(mod.loadTaskForGovernance('t-retry')).rejects.toBe(throttle); + }); + + test('seeds cost mark from task.cost_usd on a non-cost event', async () => { + // agent_milestone carries no cost metadata; the ceiling must still trip + // from the authoritative task.cost_usd floor (may arrive as a string). + mockSend.mockResolvedValue({}); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + const result = await mod.evaluateAsyncEventRules( + { + task_id: 't-seed', + event_id: 'e-seed', + event_type: 'agent_milestone', + metadata: { milestone: 'pr_created' }, + }, + { + task: { + task_id: 't-seed', + status: 'RUNNING', + cost_usd: '30' as any, // doc-client string; floors the mark ≥ 25 + event_rules: [ + { + id: 'cap', + on: 'pr_created', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'escalate', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ); + // escalate with no notify_channels falls back to the default set. + expect(result.notifyChannels).toEqual(expect.arrayContaining(['email', 'slack'])); + }); + + test('rethrows retryable error from high-water update', async () => { + const throttle = Object.assign(new Error('throttled'), { name: 'ThrottlingException' }); + mockSend.mockRejectedValueOnce(throttle); // high-water UpdateCommand fails + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await expect( + mod.evaluateAsyncEventRules( + { + task_id: 't-hw', + event_id: 'e-hw', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + task: { + task_id: 't-hw', + status: 'RUNNING', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ), + ).rejects.toBe(throttle); + }); + + test('executes cancel_task action in enforce mode', async () => { + mockSend + .mockResolvedValueOnce({}) // idempotency + .mockResolvedValueOnce({}) // Put policy_decision + .mockResolvedValueOnce({}) // Update task cancel + .mockResolvedValueOnce({}); // Put task_cancelled event + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await mod.evaluateAsyncEventRules( + { + task_id: 't2', + event_id: 'e2', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + aggregateState: { cumulative_cost_usd: 30 }, + task: { + task_id: 't2', + status: 'RUNNING', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ); + expect(mockSend).toHaveBeenCalled(); + }); + + test('observe_only notify does NOT push channels or force fan-out', async () => { + mockSend + .mockResolvedValueOnce({}) // idempotency claim + .mockResolvedValueOnce({}); // Put policy_decision (audit only) + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + const result = await mod.evaluateAsyncEventRules( + { + task_id: 't-obs', + event_id: 'e-obs', + event_type: 'agent_milestone', + metadata: { milestone: 'pr_created' }, + }, + { + task: { + task_id: 't-obs', + event_rules: [ + { + id: 'notify-observe', + on: 'pr_created', + action: 'notify', + mode: 'observe_only', + evaluation: 'async', + notify_channels: ['slack'], + }, + ], + } as any, + }, + ); + // "Would have fired": audit record written, but no action. + expect(result.notifyChannels).toEqual([]); + expect(result.forceFanOut).toBe(false); + }); + + test('enforce require_approval creates an approval row and forces fan-out', async () => { + mockSend + .mockResolvedValueOnce({}) // idempotency claim + .mockResolvedValueOnce({}) // Put policy_decision + .mockResolvedValueOnce({}); // TransactWrite approval (RUNNING task) + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + const result = await mod.evaluateAsyncEventRules( + { + task_id: 't-appr', + event_id: 'e-appr', + event_type: 'agent_milestone', + metadata: { milestone: 'pr_created', pr_url: 'https://github.com/o/r/pull/1' }, + }, + { + task: { + task_id: 't-appr', + user_id: 'u1', + status: 'RUNNING', + event_rules: [ + { + id: 'approve-pr', + on: 'pr_created', + action: 'require_approval', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ); + expect(result.forceFanOut).toBe(true); + const approvalCall = mockSend.mock.calls.find((c) => c[0]._type === 'TransactWrite'); + expect(approvalCall).toBeDefined(); + }); + + test('observe_only require_approval does NOT create an approval row', async () => { + mockSend + .mockResolvedValueOnce({}) // idempotency claim + .mockResolvedValueOnce({}); // Put policy_decision (audit only) + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await mod.evaluateAsyncEventRules( + { + task_id: 't-appr-obs', + event_id: 'e-appr-obs', + event_type: 'agent_milestone', + metadata: { milestone: 'pr_created' }, + }, + { + task: { + task_id: 't-appr-obs', + user_id: 'u1', + status: 'RUNNING', + event_rules: [ + { + id: 'approve-observe', + on: 'pr_created', + action: 'require_approval', + mode: 'observe_only', + evaluation: 'async', + }, + ], + } as any, + }, + ); + expect(mockSend.mock.calls.some((c) => c[0]._type === 'TransactWrite')).toBe(false); + expect(mockSend.mock.calls.some((c) => c[0]._type === 'Put')).toBe(true); // policy_decision only + }); + + test('enforce inject_nudge writes a nudge, truncated to the max length', async () => { + process.env.NUDGES_TABLE_NAME = 'Nudges'; + mockSend + .mockResolvedValueOnce({}) // idempotency claim + .mockResolvedValueOnce({}) // Put policy_decision + .mockResolvedValueOnce({}); // Put nudge + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + const longReason = 'x'.repeat(5000); + await mod.evaluateAsyncEventRules( + { + task_id: 't-nudge', + event_id: 'e-nudge', + event_type: 'agent_milestone', + metadata: { milestone: 'plan_ready' }, + }, + { + task: { + task_id: 't-nudge', + user_id: 'u1', + status: 'RUNNING', + event_rules: [ + { + id: 'nudge', + on: 'plan_ready', + action: 'inject_nudge', + mode: 'enforce', + evaluation: 'async', + reason: longReason, + }, + ], + } as any, + }, + ); + const nudgeCall = mockSend.mock.calls.find( + (c) => c[0]._type === 'Put' && c[0].input?.Item?.source === 'event_rule' && c[0].input?.Item?.nudge_id, + ); + expect(nudgeCall).toBeDefined(); + expect(nudgeCall![0].input.Item.message.length).toBeLessThanOrEqual(2000); + delete process.env.NUDGES_TABLE_NAME; + }); + + test('idempotency dedup skips the action on a replayed record', async () => { + // The idempotency claim (a conditional Put of a governance_idempotency row) + // fails → the marker already exists → the cancel_task transition must be + // suppressed. Route the CCF to the claim Put only, by command shape. + const ccf = Object.assign(new Error('exists'), { name: 'ConditionalCheckFailedException' }); + mockSend.mockImplementation((cmd: any) => + cmd?._type === 'Put' && cmd.input?.Item?.event_type === 'governance_idempotency' + ? Promise.reject(ccf) + : Promise.resolve({}), + ); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await mod.evaluateAsyncEventRules( + { + task_id: 't-dup', + event_id: 'e-dup', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + aggregateState: { cumulative_cost_usd: 30 }, + task: { + task_id: 't-dup', + status: 'RUNNING', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ); + // The high-water persist may run, but the cancel transition must NOT — the + // replayed record was deduped by the failed idempotency claim. + expect(mockSend.mock.calls.some(isCancelUpdate)).toBe(false); + }); + + // The cancel_task write is the only Update carrying a #status transition; + // the high-water-mark persist is also an Update, so tests key on #status. + const isCancelUpdate = (c: any) => + c[0]._type === 'Update' && String(c[0].input?.UpdateExpression ?? '').includes('#status'); + + test('cancel_task is skipped when the task is already terminal', async () => { + mockSend.mockResolvedValue({}); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await mod.evaluateAsyncEventRules( + { + task_id: 't-term', + event_id: 'e-term', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + aggregateState: { cumulative_cost_usd: 30 }, + task: { + task_id: 't-term', + status: 'COMPLETED', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ); + // No cancel transition issued against an already-terminal task. + expect(mockSend.mock.calls.some(isCancelUpdate)).toBe(false); + }); + + test('retryable error in cancel_task releases idempotency and rethrows', async () => { + const throttle = Object.assign(new Error('throttled'), { name: 'ThrottlingException' }); + // Route by command shape so we don't depend on call ordering: only the + // cancel transition throttles; everything else (high-water, claim, audit, + // release) succeeds. + mockSend.mockImplementation((cmd: any) => + isCancelUpdate([cmd]) ? Promise.reject(throttle) : Promise.resolve({}), + ); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await expect( + mod.evaluateAsyncEventRules( + { + task_id: 't-throttle', + event_id: 'e-throttle', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + aggregateState: { cumulative_cost_usd: 30 }, + task: { + task_id: 't-throttle', + status: 'RUNNING', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ), + ).rejects.toBe(throttle); + // The idempotency marker was released (Delete) so the retried record re-runs. + expect(mockSend.mock.calls.some((c) => c[0]._type === 'Delete')).toBe(true); + }); + + const isPolicyDecisionPut = (c: any) => + c[0]._type === 'Put' && c[0].input?.Item?.event_type === 'policy_decision'; + const isIdemClaim = (c: any) => + c[0]._type === 'Put' && c[0].input?.Item?.event_type === 'governance_idempotency'; + + test('retryable error in policy_decision emit releases idempotency and rethrows', async () => { + // The audit emit runs AFTER the claim but must be release-guarded too: if it + // throttles, the marker has to be released so the retry re-claims and re-runs + // rather than being deduped away with the enforcement action never fired. + const throttle = Object.assign(new Error('throttled'), { name: 'ThrottlingException' }); + mockSend.mockImplementation((cmd: any) => + isPolicyDecisionPut([cmd]) ? Promise.reject(throttle) : Promise.resolve({}), + ); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await expect( + mod.evaluateAsyncEventRules( + { + task_id: 't-emit', + event_id: 'e-emit', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + aggregateState: { cumulative_cost_usd: 30 }, + task: { + task_id: 't-emit', + status: 'RUNNING', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ), + ).rejects.toBe(throttle); + expect(mockSend.mock.calls.some((c) => c[0]._type === 'Delete')).toBe(true); + // The enforcement action never ran (emit failed first). + expect(mockSend.mock.calls.some(isCancelUpdate)).toBe(false); + }); + + test('cancel_task swallows a benign ConditionalCheckFailedException (terminal race)', async () => { + // Task passed the loaded-snapshot terminal check but raced to terminal before + // the Update — the climb-only condition rejects it. That must NOT park the + // record in batchItemFailures; it should be swallowed and NOT released. + const ccf = Object.assign(new Error('stale'), { name: 'ConditionalCheckFailedException' }); + mockSend.mockImplementation((cmd: any) => + isCancelUpdate([cmd]) ? Promise.reject(ccf) : Promise.resolve({}), + ); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + // Resolves (no throw) — the record is considered handled. + await expect( + mod.evaluateAsyncEventRules( + { + task_id: 't-race', + event_id: 'e-race', + event_type: 'agent_cost_update', + metadata: { cumulative_cost_usd: 30 }, + }, + { + aggregateState: { cumulative_cost_usd: 30 }, + task: { + task_id: 't-race', + status: 'RUNNING', + event_rules: [ + { + id: 'cap', + on: 'agent_cost_update', + when: { aggregate: { cost_usd_gte: 25 } }, + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }, + ], + } as any, + }, + ), + ).resolves.toBeDefined(); + // Benign CCF is not a failure — marker stays claimed, no release. + expect(mockSend.mock.calls.some((c) => c[0]._type === 'Delete')).toBe(false); + }); + + test('retryable error in idempotency claim rethrows instead of double-firing', async () => { + // A throttle on the claim Put must retry the whole record, not proceed on an + // unclaimed marker (which would risk a double notify/escalate on retry). + const throttle = Object.assign(new Error('throttled'), { name: 'ProvisionedThroughputExceededException' }); + mockSend.mockImplementation((cmd: any) => + isIdemClaim([cmd]) ? Promise.reject(throttle) : Promise.resolve({}), + ); + const mod = await import('../../../src/handlers/shared/event-governance-async'); + mod._resetGovernanceIdempotencyCache(); + await expect( + mod.evaluateAsyncEventRules( + { + task_id: 't-claim', + event_id: 'e-claim', + event_type: 'agent_milestone', + metadata: { milestone: 'pr_created' }, + }, + { + task: { + task_id: 't-claim', + status: 'RUNNING', + event_rules: [ + { + id: 'notify', + on: 'pr_created', + action: 'notify', + mode: 'enforce', + evaluation: 'async', + notify_channels: ['slack'], + }, + ], + } as any, + }, + ), + ).rejects.toBe(throttle); + // No audit emit — we bailed at the claim, before any side effect. + expect(mockSend.mock.calls.some(isPolicyDecisionPut)).toBe(false); + }); +}); + diff --git a/cdk/test/handlers/shared/event-governance-async.test.ts b/cdk/test/handlers/shared/event-governance-async.test.ts new file mode 100644 index 00000000..bf4b4278 --- /dev/null +++ b/cdk/test/handlers/shared/event-governance-async.test.ts @@ -0,0 +1,46 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Async event governance tests (issue #230). + */ + +import { matchEventRules, parseEventRules } from '../../../src/handlers/shared/event-rule-evaluator'; + +describe('event-governance-async matching', () => { + test('async notify rule matches pr_created milestone', () => { + const rules = parseEventRules([ + { + id: 'notify-on-pr', + on: 'pr_created', + action: 'notify', + mode: 'enforce', + evaluation: 'async', + notify_channels: ['slack'], + }, + ]); + const matched = matchEventRules( + rules, + { event_type: 'agent_milestone', metadata: { milestone: 'pr_created' } }, + { evaluation: 'async' }, + ); + expect(matched.map(r => r.id)).toEqual(['notify-on-pr']); + expect(matched[0].notify_channels).toContain('slack'); + }); +}); diff --git a/cdk/test/handlers/shared/event-rule-evaluator.test.ts b/cdk/test/handlers/shared/event-rule-evaluator.test.ts new file mode 100644 index 00000000..ae1a85fe --- /dev/null +++ b/cdk/test/handlers/shared/event-rule-evaluator.test.ts @@ -0,0 +1,66 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Event rule evaluator tests (issue #230). + */ + +import aggregateFixture from '../../../../agent/event-rules/fixtures/aggregate-cost-cancel.json'; +import observeFixture from '../../../../agent/event-rules/fixtures/observe-repo-setup.json'; +import type { EventRule } from '../../../src/handlers/shared/event-governance-types'; +import { + buildPolicyDecisionMetadata, + matchEventRules, + parseEventRules, +} from '../../../src/handlers/shared/event-rule-evaluator'; + +describe('event-rule-evaluator', () => { + test('matches observe-repo-setup fixture', () => { + const rules = parseEventRules(observeFixture.rules); + const matched = matchEventRules(rules, observeFixture.event, { evaluation: 'sync' }); + expect(matched.map((r: EventRule) => r.id)).toEqual(observeFixture.expected_matches); + }); + + test('matches aggregate-cost-cancel fixture', () => { + const rules = parseEventRules(aggregateFixture.rules); + const matched = matchEventRules(rules, aggregateFixture.event, { + evaluation: 'async', + aggregateState: aggregateFixture.aggregate_state, + }); + expect(matched.map((r: EventRule) => r.id)).toEqual(aggregateFixture.expected_matches); + }); + + test('buildPolicyDecisionMetadata marks would_block for enforce require_approval', () => { + const rules = parseEventRules([ + { + id: 'gate', + on: 'checkpoint:before_execution', + action: 'require_approval', + mode: 'enforce', + evaluation: 'sync', + }, + ]); + const meta = buildPolicyDecisionMetadata(rules[0], { + event_type: 'agent_milestone', + metadata: { milestone: 'checkpoint:before_execution', checkpoint: 'checkpoint:before_execution' }, + }, true); + expect(meta.would_block).toBe(true); + expect(meta.source).toBe('event_rule'); + }); +}); diff --git a/cdk/test/handlers/shared/event-rule-pack-resolver.test.ts b/cdk/test/handlers/shared/event-rule-pack-resolver.test.ts new file mode 100644 index 00000000..781966c5 --- /dev/null +++ b/cdk/test/handlers/shared/event-rule-pack-resolver.test.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { EventRule } from '../../../src/handlers/shared/event-governance-types'; +import { + resolveEventRules, + UnknownEventRulePackError, + listBuiltinEventRulePacks, +} from '../../../src/handlers/shared/event-rule-pack-resolver'; + +const PACK = { id: 'platform-default', version: '1.0.0' } as const; + +describe('resolveEventRules', () => { + test('returns inline rules verbatim when no pack is pinned', () => { + const inline: EventRule[] = [ + { id: 'a', on: 'pr_created', action: 'notify', mode: 'enforce', evaluation: 'async' }, + ]; + expect(resolveEventRules({ inlineRules: inline })).toEqual(inline); + }); + + test('returns pack rules (tagged with rule_pack_id) when no inline rules', () => { + const rules = resolveEventRules({ packRef: PACK }); + expect(rules.map((r) => r.id)).toEqual(expect.arrayContaining(['observe-repo-setup', 'notify-on-pr'])); + expect(rules.every((r) => r.rule_pack_id === 'platform-default')).toBe(true); + }); + + test('inline rule with same id overrides the pack rule', () => { + const override: EventRule = { + id: 'notify-on-pr', + on: 'pr_created', + action: 'cancel_task', + mode: 'enforce', + evaluation: 'async', + }; + const rules = resolveEventRules({ packRef: PACK, inlineRules: [override] }); + const merged = rules.find((r) => r.id === 'notify-on-pr'); + // The inline override replaces the pack's action wholesale. + expect(merged?.action).toBe('cancel_task'); + // The other pack rule is retained. + expect(rules.find((r) => r.id === 'observe-repo-setup')).toBeDefined(); + }); + + test('inline-only rule is appended alongside pack rules', () => { + const extra: EventRule = { + id: 'inline-extra', + on: 'agent_turn', + action: 'escalate', + mode: 'enforce', + evaluation: 'async', + }; + const rules = resolveEventRules({ packRef: PACK, inlineRules: [extra] }); + expect(rules.map((r) => r.id)).toEqual( + expect.arrayContaining(['observe-repo-setup', 'notify-on-pr', 'inline-extra']), + ); + }); + + test('throws UnknownEventRulePackError for an unknown pin', () => { + expect(() => resolveEventRules({ packRef: { id: 'nope', version: '0.0.1' } })) + .toThrow(UnknownEventRulePackError); + }); + + test('throws UnknownEventRulePackError for a known id at an unknown version', () => { + expect(() => resolveEventRules({ packRef: { id: 'platform-default', version: '9.9.9' } })) + .toThrow(UnknownEventRulePackError); + }); + + test('listBuiltinEventRulePacks reports the bundled pack', () => { + const packs = listBuiltinEventRulePacks(); + expect(packs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'platform-default', version: '1.0.0' }), + ]), + ); + expect(packs[0].rule_count).toBeGreaterThan(0); + }); +}); diff --git a/cdk/test/handlers/shared/event-rules-parity.test.ts b/cdk/test/handlers/shared/event-rules-parity.test.ts new file mode 100644 index 00000000..5feda2b6 --- /dev/null +++ b/cdk/test/handlers/shared/event-rules-parity.test.ts @@ -0,0 +1,54 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { matchEventRules, parseEventRules } from '../../../src/handlers/shared/event-rule-evaluator'; + +const FIXTURE_DIR = path.resolve(__dirname, '..', '..', '..', '..', 'agent', 'event-rules', 'fixtures'); + +interface FixtureFile { + description?: string; + event: { event_type: string; metadata?: Record }; + rules: unknown[]; + expected_matches: string[]; + aggregate_state?: { cumulative_cost_usd?: number; turn_count?: number }; +} + +function loadFixtures(): FixtureFile[] { + return fs.readdirSync(FIXTURE_DIR) + .filter(f => f.endsWith('.json')) + .map(f => JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, f), 'utf8')) as FixtureFile); +} + +describe('event-rules parity (TS evaluator)', () => { + it.each(loadFixtures().map(f => [path.basename(f.description ?? 'fixture'), f]))( + '%s', + (_name, fixture) => { + const rules = parseEventRules(fixture.rules); + const matched = matchEventRules(rules, { + event_type: fixture.event.event_type, + metadata: fixture.event.metadata ?? {}, + }, { + aggregateState: fixture.aggregate_state, + }).map(r => r.id); + expect(matched.sort()).toEqual([...fixture.expected_matches].sort()); + }, + ); +}); diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 7d908d9a..a6aec16d 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -33,6 +33,7 @@ import { DenyRequest, DenyResponse, ErrorResponse, + GetEventRulesResponse, GetPendingResponse, GetPoliciesResponse, JiraLinkResponse, @@ -285,6 +286,18 @@ export class ApiClient { return res.data; } + /** GET /repos/{repo_id}/event-rules — resolved event governance rules. */ + async listEventRules(repoId: string, workflowRef?: string): Promise { + const query = workflowRef + ? `?workflow_ref=${encodeURIComponent(workflowRef)}` + : ''; + const res = await this.request>( + 'GET', + `/repos/${encodeURIComponent(repoId)}/event-rules${query}`, + ); + return res.data; + } + /** * GET /tasks/{task_id}/events — fetch one page of task events. * diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 5faf408f..0a94bfab 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -38,6 +38,7 @@ import { makePlatformCommand } from '../commands/platform'; import { makePoliciesCommand } from '../commands/policies'; import { makeReplayCommand } from '../commands/replay'; import { makeRepoCommand } from '../commands/repo'; +import { makeRulesCommand } from '../commands/rules'; import { makeRuntimeCommand } from '../commands/runtime'; import { makeSlackCommand } from '../commands/slack'; import { makeStatusCommand } from '../commands/status'; @@ -75,6 +76,7 @@ program.addCommand(makeApproveCommand()); program.addCommand(makeDenyCommand()); program.addCommand(makePendingCommand()); program.addCommand(makePoliciesCommand()); +program.addCommand(makeRulesCommand()); program.addCommand(makeEventsCommand()); program.addCommand(makeSlackCommand()); program.addCommand(makeLinearCommand()); diff --git a/cli/src/commands/pending.ts b/cli/src/commands/pending.ts index 2f8266fe..dd11f856 100644 --- a/cli/src/commands/pending.ts +++ b/cli/src/commands/pending.ts @@ -56,9 +56,15 @@ function renderText(pending: readonly PendingApprovalSummary[]): void { } console.log(`${pending.length} pending approval(s):\n`); for (const p of pending) { + const triggerLabel = p.source === 'event' + ? (p.checkpoint ?? p.event_type ?? p.tool_name) + : p.tool_name; console.log(` task_id: ${p.task_id}`); console.log(` request_id: ${p.request_id}`); - console.log(` tool: ${p.tool_name} severity: ${p.severity}`); + console.log(` trigger: ${triggerLabel} severity: ${p.severity}`); + if (p.source === 'event') { + console.log(` source: event${p.rule_id ? ` (rule ${p.rule_id})` : ''}`); + } console.log(` reason: ${p.reason}`); if (p.matching_rule_ids !== undefined && p.matching_rule_ids.length > 0) { console.log(` rules: ${p.matching_rule_ids.join(', ')}`); diff --git a/cli/src/commands/rules.ts b/cli/src/commands/rules.ts new file mode 100644 index 00000000..f2d7b482 --- /dev/null +++ b/cli/src/commands/rules.ts @@ -0,0 +1,151 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { ApiError, CliError } from '../errors'; +import { formatJson } from '../format'; + +interface FixtureFile { + description?: string; + event: { event_type: string; metadata?: Record }; + rules: unknown[]; + expected_matches: string[]; + aggregate_state?: { cumulative_cost_usd?: number; turn_count?: number }; +} + +function loadFixture(name: string): FixtureFile { + const repoRoot = path.resolve(__dirname, '../../..'); + const fixturePath = path.join(repoRoot, 'agent/event-rules/fixtures', `${name}.json`); + return JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as FixtureFile; +} + +function eventName(eventType: string, metadata: Record): string { + if (eventType === 'agent_milestone' && typeof metadata.milestone === 'string') { + return metadata.milestone; + } + if (typeof metadata.checkpoint === 'string') return metadata.checkpoint; + return eventType; +} + +// Local, deliberately-minimal matcher for the `rules eval` dry-run. It does NOT +// filter by `evaluation` (fixtures are single-mode) and is a third copy of the +// matching logic — the authoritative implementations are +// agent/src/event_governance/evaluator.py and cdk .../event-rule-evaluator.ts, +// kept in lockstep by the shared-fixture parity suites. If this drifts, those +// parity tests are the source of truth; keep this in sync by hand or route +// through a shared package when one exists. +function matchRules(fixture: FixtureFile): string[] { + const name = eventName(fixture.event.event_type, fixture.event.metadata ?? {}); + const meta = fixture.event.metadata ?? {}; + const matched: string[] = []; + for (const raw of fixture.rules) { + const rule = raw as Record; + if (rule.on !== name) continue; + const when = rule.when as { fields?: Record; aggregate?: { cost_usd_gte?: number; turn_count_gte?: number } } | undefined; + if (when?.fields) { + let ok = true; + for (const [k, v] of Object.entries(when.fields)) { + if (meta[k] !== v) ok = false; + } + if (!ok) continue; + } + if (when?.aggregate?.cost_usd_gte !== undefined) { + const cumulative = fixture.aggregate_state?.cumulative_cost_usd + ?? (meta.cumulative_cost_usd as number | undefined); + if (cumulative === undefined || cumulative < when.aggregate.cost_usd_gte) continue; + } + if (when?.aggregate?.turn_count_gte !== undefined) { + const turns = fixture.aggregate_state?.turn_count + ?? (meta.turn_count as number | undefined); + if (turns === undefined || turns < when.aggregate.turn_count_gte) continue; + } + matched.push(String(rule.id)); + } + return matched; +} + +export function makeRulesCommand(): Command { + const rules = new Command('rules') + .description('Event governance rule utilities (issue #230)'); + + rules + .command('eval') + .description('Evaluate a golden fixture locally') + .requiredOption('--fixture ', 'Fixture basename under agent/event-rules/fixtures/') + .option('--output ', 'Output format (text or json)', 'text') + .action((opts: { fixture: string; output: string }) => { + const fixture = loadFixture(opts.fixture); + const matched = matchRules(fixture); + const payload = { + fixture: opts.fixture, + description: fixture.description, + matched, + expected: fixture.expected_matches, + ok: matched.join(',') === fixture.expected_matches.join(','), + }; + if (opts.output === 'json') { + console.log(formatJson(payload)); + return; + } + console.log(fixture.description ?? opts.fixture); + console.log(`matched: ${matched.join(', ') || '(none)'}`); + console.log(`expected: ${fixture.expected_matches.join(', ')}`); + console.log(payload.ok ? 'OK' : 'MISMATCH'); + if (!payload.ok) process.exitCode = 1; + }); + + rules + .command('list') + .description('List resolved event governance rules for a repo') + .requiredOption('--repo ', 'Repository identifier') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (opts: { repo: string; output: string }) => { + const client = new ApiClient(); + try { + const result = await client.listEventRules(opts.repo); + if (opts.output === 'json') { + console.log(formatJson(result)); + return; + } + console.log(`Event rules for ${result.repo_id}:`); + if (result.event_rule_pack) { + console.log(` pack: ${result.event_rule_pack.id}@${result.event_rule_pack.version}`); + } + if (result.rules.length === 0) { + console.log(' (no rules configured)'); + return; + } + for (const rule of result.rules) { + const mode = rule.mode === 'observe_only' ? '[observe] ' : ''; + console.log(` - ${mode}${rule.rule_id}: on=${rule.on} action=${rule.action} (${rule.evaluation})`); + if (rule.reason) console.log(` ${rule.reason}`); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw new CliError(err.message); + } + throw err; + } + }); + + return rules; +} diff --git a/cli/src/commands/submit.ts b/cli/src/commands/submit.ts index 8fd3feec..ef2809a7 100644 --- a/cli/src/commands/submit.ts +++ b/cli/src/commands/submit.ts @@ -97,6 +97,7 @@ export function makeSubmitCommand(): Command { [] as readonly string[], ) .option('--wait', 'Wait for task to complete') + .option('--governance-preview', 'Print resolved event governance rules before submitting') .option('--output ', 'Output format (text or json)', 'text') .action(async (opts) => { if (opts.pr !== undefined && isNaN(opts.pr)) { @@ -213,6 +214,27 @@ export function makeSubmitCommand(): Command { const prNumber = opts.pr ?? opts.reviewPr; const client = new ApiClient(); + + if (opts.governancePreview && opts.repo) { + const gov = await client.listEventRules(opts.repo, workflowRef); + process.stderr.write(`Governance preview for ${gov.repo_id}:\n`); + if (gov.event_rule_pack) { + process.stderr.write(` pack: ${gov.event_rule_pack.id}@${gov.event_rule_pack.version}\n`); + } + const syncGates = gov.rules.filter(r => r.evaluation === 'sync' && r.action === 'require_approval'); + const asyncRules = gov.rules.filter(r => r.evaluation === 'async'); + process.stderr.write(` sync approval gates: ${syncGates.length}\n`); + for (const r of syncGates) { + const tag = r.mode === 'observe_only' ? '[observe] ' : ''; + process.stderr.write(` - ${tag}${r.rule_id} @ ${r.on}\n`); + } + process.stderr.write(` async rules: ${asyncRules.length}\n`); + for (const r of asyncRules) { + process.stderr.write(` - ${r.rule_id}: ${r.action} on ${r.on}\n`); + } + process.stderr.write('\n'); + } + const body: CreateTaskRequest = { // Omitted entirely for a repo-less workflow (#248 Phase 3) — sending // ``repo: undefined`` would serialize as an explicit null on some paths. diff --git a/cli/src/commands/watch.ts b/cli/src/commands/watch.ts index 6cefbfab..0aad1c6f 100644 --- a/cli/src/commands/watch.ts +++ b/cli/src/commands/watch.ts @@ -113,6 +113,7 @@ const PROGRESS_EVENT_TYPES = new Set([ 'agent_cost_update', 'agent_error', 'agent_blocked', + 'policy_decision', ]); /** Format an event timestamp to a short local time string. */ @@ -234,6 +235,27 @@ export function renderEvent(event: TaskEvent): string { } return line; } + case 'policy_decision': { + const mode = meta.enforcement_mode === 'observe_only' ? '[observe] ' : ''; + const ruleIds = meta.matching_rule_ids; + const firstRule = Array.isArray(ruleIds) && ruleIds.length > 0 ? String(ruleIds[0]) : undefined; + const ruleId = meta.rule_id != null ? String(meta.rule_id) : (firstRule ?? 'rule'); + const action = meta.action ?? meta.decision ?? 'observe'; + const trigger = meta.checkpoint ?? meta.trigger_milestone ?? meta.trigger_event_type ?? ''; + const would = meta.would_block ? ' (would block)' : ''; + const prNote = trigger === 'pr_created' && action === 'require_approval' + ? ' — PR already exists' + : ''; + if (action === 'require_approval' && meta.enforcement_mode === 'enforce' && !meta.would_block) { + const label = trigger === 'checkpoint:before_execution' + ? 'Plan verified — awaiting your approval' + : trigger === 'checkpoint:before_open_pr' + ? 'Pre-PR review — awaiting your approval' + : `Approval required at ${trigger || 'checkpoint'}`; + return `[${time}] ${mode}${label}: rule "${ruleId}"${prNote}`; + } + return `[${time}] ${mode}Policy ${action} on ${trigger}: rule "${ruleId}"${would}${prNote}`; + } default: return `[${time}] ${event.event_type}: ${JSON.stringify(meta)}`; } diff --git a/cli/src/types.ts b/cli/src/types.ts index 3ee7f91f..92e89b66 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -548,6 +548,64 @@ export interface DenyResponse { */ export type Severity = 'low' | 'medium' | 'high'; +/** Event governance types (issue #230). Mirrors ``cdk/src/handlers/shared/event-governance-types.ts``. */ +export type EventRuleAction = + | 'require_approval' + | 'notify' + | 'escalate' + | 'cancel_task' + | 'inject_nudge' + | 'observe_only'; + +export type EventRuleMode = 'observe_only' | 'enforce'; +export type EventRuleEvaluation = 'sync' | 'async'; +export type ApprovalSource = 'tool' | 'event'; + +export type EventRuleWhen = { + readonly fields?: Readonly>; + readonly aggregate?: { + readonly cost_usd_gte?: number; + readonly turn_count_gte?: number; + }; +}; + +export type EventRule = { + readonly id: string; + readonly on: string; + readonly when?: EventRuleWhen; + readonly action: EventRuleAction; + readonly mode: EventRuleMode; + readonly evaluation: EventRuleEvaluation; + readonly reason?: string; + readonly severity?: 'low' | 'medium' | 'high'; + readonly timeout_s?: number; + readonly notify_channels?: readonly ('slack' | 'email' | 'github' | 'linear' | 'webhook')[]; + readonly rule_pack_id?: string; +}; + +export type EventRulePackRef = { + readonly id: string; + readonly version: string; +}; + +export type PolicyDecisionMetadata = { + readonly decision: 'allow' | 'deny' | 'require_approval' | 'observe'; + readonly source: 'cedar_tool' | 'event_rule' | 'submission'; + readonly enforcement_mode: EventRuleMode; + readonly rule_id?: string; + readonly rule_pack_id?: string; + readonly trigger_event_type?: string; + readonly trigger_milestone?: string; + readonly checkpoint?: string; + readonly correlation_id?: string; + readonly matching_rule_ids?: readonly string[]; + readonly reason?: string; + readonly severity?: string; + readonly timeout_s?: number; + readonly action?: string; + readonly would_block?: boolean; +}; + /** Pending approval summary returned by `GET /v1/pending`. */ export interface PendingApprovalSummary { readonly task_id: string; @@ -563,6 +621,10 @@ export interface PendingApprovalSummary { * ``bgagent pending`` so users can see which rule fired without * spelunking TaskEventsTable. */ readonly matching_rule_ids: readonly string[]; + readonly source?: ApprovalSource; + readonly event_type?: string; + readonly checkpoint?: string; + readonly rule_id?: string; } /** GET /v1/pending response body. */ @@ -588,6 +650,28 @@ export interface GetPoliciesResponse { }; } +/** Event rule summary from GET /v1/repos/{repo_id}/event-rules. */ +export interface EventRuleSummary { + readonly rule_id: string; + readonly on: string; + readonly action: string; + readonly mode: string; + readonly evaluation: string; + readonly reason?: string; +} + +/** GET /v1/repos/{repo_id}/event-rules response body. */ +export interface GetEventRulesResponse { + readonly repo_id: string; + readonly event_rule_pack?: EventRulePackRef; + readonly rules: readonly EventRuleSummary[]; + readonly registry_packs: readonly { + readonly id: string; + readonly version: string; + readonly rule_count: number; + }[]; +} + /** Maximum deny reason length after server-side sanitization. */ export const DENY_REASON_MAX_LENGTH = 2000; diff --git a/cli/test/commands/rules.test.ts b/cli/test/commands/rules.test.ts new file mode 100644 index 00000000..e583675d --- /dev/null +++ b/cli/test/commands/rules.test.ts @@ -0,0 +1,135 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * rules command tests (issue #230). + */ + +import { ApiClient } from '../../src/api-client'; +import { makeRulesCommand } from '../../src/commands/rules'; +import { ApiError } from '../../src/errors'; + +jest.mock('../../src/api-client'); + +/** Run `rules eval` for a fixture and capture stdout + resulting exit code. */ +async function runEval(args: string[]): Promise<{ lines: string[]; exitCode: number }> { + const cmd = makeRulesCommand(); + const prevExitCode = process.exitCode; + process.exitCode = undefined; + const lines: string[] = []; + const logSpy = jest.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + lines.push(a.map(String).join(' ')); + }); + try { + await cmd.parseAsync(['eval', ...args], { from: 'user' }); + return { lines, exitCode: process.exitCode ?? 0 }; + } finally { + logSpy.mockRestore(); + process.exitCode = prevExitCode; + } +} + +describe('bgagent rules eval', () => { + test('matches observe-repo-setup fixture (text output)', async () => { + const { lines, exitCode } = await runEval(['--fixture', 'observe-repo-setup']); + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('OK'); + }); + + test('matches aggregate cost-ceiling fixture', async () => { + const { lines, exitCode } = await runEval(['--fixture', 'aggregate-cost-cancel']); + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('cost-ceiling'); + }); + + test('matches aggregate turn-count fixture', async () => { + const { lines, exitCode } = await runEval(['--fixture', 'aggregate-turn-count-escalate']); + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('turn-ceiling'); + }); + + test('matches async notify-on-pr fixture (milestone event name)', async () => { + const { lines, exitCode } = await runEval(['--fixture', 'async-notify-pr']); + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('notify-on-pr'); + }); + + test('emits JSON when --output json', async () => { + const { lines } = await runEval(['--fixture', 'observe-repo-setup', '--output', 'json']); + const parsed = JSON.parse(lines.join('\n')); + expect(parsed.ok).toBe(true); + expect(parsed.matched).toEqual(parsed.expected); + }); +}); + +describe('bgagent rules list', () => { + const mockListEventRules = jest.fn(); + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + mockListEventRules.mockReset(); + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + (ApiClient as jest.MockedClass).mockImplementation(() => ({ + listEventRules: mockListEventRules, + }) as unknown as ApiClient); + }); + + afterEach(() => consoleSpy.mockRestore()); + + async function runList(args: string[]): Promise { + await makeRulesCommand().parseAsync(['list', ...args], { from: 'user' }); + } + + test('renders rules with pack and reason', async () => { + mockListEventRules.mockResolvedValue({ + repo_id: 'org/repo', + event_rule_pack: { id: 'platform-default', version: 'v1' }, + rules: [ + { rule_id: 'r1', on: 'pr_created', action: 'notify', mode: 'enforce', evaluation: 'async', reason: 'ping' }, + { rule_id: 'r2', on: 'checkpoint:before_execution', action: 'require_approval', mode: 'observe_only', evaluation: 'sync' }, + ], + registry_packs: [], + }); + await runList(['--repo', 'org/repo']); + const out = consoleSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(out).toContain('platform-default@v1'); + expect(out).toContain('r1'); + expect(out).toContain('ping'); + expect(out).toContain('[observe] r2'); + }); + + test('reports empty rule set', async () => { + mockListEventRules.mockResolvedValue({ repo_id: 'org/repo', rules: [], registry_packs: [] }); + await runList(['--repo', 'org/repo']); + const out = consoleSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(out).toContain('(no rules configured)'); + }); + + test('emits JSON when --output json', async () => { + const payload = { repo_id: 'org/repo', rules: [], registry_packs: [] }; + mockListEventRules.mockResolvedValue(payload); + await runList(['--repo', 'org/repo', '--output', 'json']); + expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual(payload); + }); + + test('wraps ApiError as CliError', async () => { + mockListEventRules.mockRejectedValue(new ApiError(500, 'INTERNAL', 'boom', 'req-1')); + await expect(runList(['--repo', 'org/repo'])).rejects.toThrow('boom'); + }); +}); diff --git a/cli/test/commands/watch.test.ts b/cli/test/commands/watch.test.ts index 2cc65a32..ab12e2a5 100644 --- a/cli/test/commands/watch.test.ts +++ b/cli/test/commands/watch.test.ts @@ -159,6 +159,23 @@ describe('renderEvent', () => { expect(output).toContain('rules=deny-net'); }); + test('renders top-level policy_decision event (event governance #230)', () => { + const event = makeEvent({ + event_type: 'policy_decision', + metadata: { + rule_id: 'plan-review', + action: 'require_approval', + enforcement_mode: 'observe_only', + checkpoint: 'checkpoint:before_execution', + would_block: true, + }, + }); + const output = renderEvent(event); + expect(output).toContain('[observe]'); + expect(output).toContain('plan-review'); + expect(output).toContain('would block'); + }); + test('falls back to a compact JSON dump for unrecognized milestone metadata', () => { const event = makeEvent({ event_type: 'agent_milestone', diff --git a/contracts/event-catalog/v1.json b/contracts/event-catalog/v1.json new file mode 100644 index 00000000..e172766b --- /dev/null +++ b/contracts/event-catalog/v1.json @@ -0,0 +1,62 @@ +{ + "catalog_version": "1.0.0", + "top_level_event_types": [ + "task_created", + "hydration_started", + "hydration_complete", + "session_started", + "task_completed", + "task_failed", + "task_cancelled", + "task_timed_out", + "task_stranded", + "admission_rejected", + "preflight_failed", + "guardrail_blocked", + "uploads_confirmed", + "attachment_blocked", + "pending_upload_expired", + "task_cancel_compute_orphan", + "approval_decision_recorded", + "agent_turn", + "agent_tool_call", + "agent_tool_result", + "agent_milestone", + "agent_cost_update", + "agent_error", + "policy_decision" + ], + "agent_milestones": [ + "repo_setup_complete", + "attachments_downloaded", + "agent_execution_complete", + "pr_created", + "task_cancelled_acknowledged", + "trace_truncated", + "trajectory_uploaded", + "nudge_acknowledged", + "cancel_detected", + "pre_approvals_loaded", + "approval_requested", + "approval_granted", + "approval_denied", + "approval_timed_out", + "approval_stranded", + "approval_write_failed", + "approval_resume_failed", + "approval_poll_degraded", + "approval_timeout_capped", + "approval_timeout_capped_at_submit", + "approval_ceiling_shrinking", + "approval_cap_exceeded", + "approval_rate_limit_exceeded", + "approval_late_win", + "policy_decision", + "status_response", + "delivered_comment" + ], + "checkpoints": [ + "checkpoint:before_execution", + "checkpoint:before_open_pr" + ] +} diff --git a/contracts/policy-decision/schema.json b/contracts/policy-decision/schema.json new file mode 100644 index 00000000..f3b1cb84 --- /dev/null +++ b/contracts/policy-decision/schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abca.dev/contracts/policy-decision/schema.json", + "title": "PolicyDecisionEventMetadata", + "type": "object", + "required": ["decision", "source", "enforcement_mode"], + "properties": { + "decision": { + "type": "string", + "enum": ["allow", "deny", "require_approval", "observe"] + }, + "source": { + "type": "string", + "enum": ["cedar_tool", "event_rule", "submission"] + }, + "enforcement_mode": { + "type": "string", + "enum": ["observe_only", "enforce"] + }, + "rule_id": { "type": "string" }, + "rule_pack_id": { "type": "string" }, + "trigger_event_type": { "type": "string" }, + "trigger_milestone": { "type": "string" }, + "checkpoint": { "type": "string" }, + "correlation_id": { "type": "string" }, + "matching_rule_ids": { + "type": "array", + "items": { "type": "string" } + }, + "reason": { "type": "string" }, + "severity": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + }, + "timeout_s": { "type": "integer" }, + "action": { "type": "string" }, + "would_block": { "type": "boolean" } + }, + "additionalProperties": true +} diff --git a/docs/design/EVENT_GOVERNANCE.md b/docs/design/EVENT_GOVERNANCE.md new file mode 100644 index 00000000..46b3c2a5 --- /dev/null +++ b/docs/design/EVENT_GOVERNANCE.md @@ -0,0 +1,170 @@ +# Event-Driven Governance and Actions + +> **Status:** Active design (issue #230) +> **Last updated:** 2026-06-12 + +--- + +## Executive summary + +Today, human-in-the-loop (HITL) and most governance controls are **synchronous and tool-centric**: Cedar policies in `PreToolUse` gate Bash, Write, Read, etc. Operators configure bash patterns and tool shapes — not semantic moments like "plan ready", "PR opened", or "cumulative cost exceeded $25". + +Meanwhile, **observability and notifications are already event-driven** via `TaskEventsTable` and `FanOutConsumer`, but that plane cannot prevent side effects unless something blocked earlier on the hot path. + +This document defines a unified **Event Governance** layer: a normative event catalog, declarative **event rules** (condition → action), **sync** (in-agent, can block) vs **async** (stream consumer, react only) evaluation modes, **registry-native configuration** (versioned `event-rule-pack` assets pinned by blueprints), and **UX as a first-class requirement** (`bgagent submit` governance preview, unified `bgagent pending` for event- and tool-sourced approvals). Tool-level Cedar HITL remains the fail-closed safety net for execution. + +**Related:** [CEDAR_HITL_GATES.md](./CEDAR_HITL_GATES.md), [INTERACTIVE_AGENTS.md](./INTERACTIVE_AGENTS.md), [ORCHESTRATOR.md](./ORCHESTRATOR.md), [SECURITY.md](./SECURITY.md). + +--- + +## 1. Two planes, one catalog + +### 1.1 Normative event catalog + +Stable names and schemas for lifecycle, execution, milestone, checkpoint, and policy events. Machine-readable catalog at `contracts/event-catalog/v1.json` with additive versioning (`catalog_version`). + +### 1.2 Event rules + +Each rule has: + +| Field | Description | +|-------|-------------| +| `id` | Stable rule identifier | +| `on` | Event name (top-level `event_type` or milestone/checkpoint name) | +| `when` | Optional field matchers and aggregate conditions | +| `action` | `require_approval`, `notify`, `escalate`, `cancel_task`, `inject_nudge`, `observe_only` | +| `mode` | `observe_only` or `enforce` | +| `evaluation` | `sync` (in-agent) or `async` (stream consumer) | + +Schema: `agent/event-rules/schema.json`. + +### 1.3 Sync evaluation + +In-agent at pipeline checkpoints (`checkpoint:before_execution`, `checkpoint:before_open_pr`, etc.). Same latency class as Cedar; can transition to `AWAITING_APPROVAL` when `mode: enforce` and `action: require_approval`. + +### 1.4 Async evaluation + +`TaskEventsTable` stream consumer (folded into `FanOutConsumer` — no third Lambda until Kinesis migration). Tens–hundreds of ms; must not imply blocking unless UX is explicit. + +### 1.5 Precedence + +1. Tool Cedar **hard-deny** always wins. +2. Async never overrides sync deny. +3. Composable with existing `TaskApprovalsTable` / `bgagent approve` / `deny`. +4. Idempotency key: `(task_id, rule_id, correlation_id)`. + +--- + +## 2. PolicyDecisionEvent + +Unified audit schema at `contracts/policy-decision/schema.json`. Top-level `event_type: policy_decision` on `TaskEventsTable`. + +| Field | Purpose | +|-------|---------| +| `decision` | `allow`, `deny`, `require_approval`, `observe` | +| `source` | `cedar_tool`, `event_rule`, `submission` | +| `enforcement_mode` | `observe_only`, `enforce` | +| `rule_id`, `rule_pack_id` | Rule attribution | +| `event_type`, `correlation_id` | Trigger context | +| `matching_rule_ids` | Cedar parity where applicable | +| `reason`, `severity`, `timeout_s` | HITL metadata | + +--- + +## 3. Configuration + +### 3.1 Inline (Phase 0–2) + +Blueprint `security.eventRules` — array of rules persisted in RepoTable, frozen on TaskRecord at submit time (same pattern as `approval_gate_cap`). + +### 3.2 Registry-native (Phase 3) + +Blueprint `security.eventRulePack: { id, version }` resolves from agent asset registry. Inline rules merge as overrides; pack rules take precedence unless overridden per-rule. + +Packs are authored under `agent/event-rules/packs/*.json`, alongside `agent/workflows/` — one asset tree, one authoring UX, validated against `agent/event-rules/schema.json`. Today the CDK resolver bundles these at build time (`resolveEventRules`); its interface matches the future `RegistryService.resolve('event-rule-pack')`, so the registry swap is local to the resolver. The agent runtime never loads pack files — it receives already-resolved `event_rules` frozen on the TaskRecord at submit time, which keeps the sync/async evaluators reading one source. + +--- + +## 4. Checkpoints + +Pipeline-owned, not agent-declared: + +| Checkpoint | When | +|----------|------| +| `checkpoint:before_execution` | After repo setup, before agent loop | +| `checkpoint:before_open_pr` | Before PR creation step | + +Emitted as `agent_milestone` with `metadata.checkpoint`. + +--- + +## 5. Data model extensions + +### TaskApprovalsTable + +| Field | Values | +|-------|--------| +| `source` | `tool` (default), `event` | +| `event_type` | Trigger event when `source=event` | +| `checkpoint` | Checkpoint name when applicable | +| `rule_pack_id`, `rule_id` | Rule attribution | + +GSI `user_id-status-index` projects new fields for `bgagent pending`. + +### TaskRecord + +| Field | Purpose | +|-------|---------| +| `event_rules` | Frozen rules at submit time | +| `event_rule_pack_id`, `event_rule_pack_version` | Registry pin (Phase 3) | + +--- + +## 6. Stream consumer strategy + +`FanOutConsumer` evaluates async event rules before channel dispatch. No third DDB stream consumer until Kinesis migration (see `task-events-table.ts` architectural note). + +--- + +## 7. UX + +| Surface | Behavior | +|---------|----------| +| `bgagent watch` | Renders `policy_decision` events; `[observe]` prefix for observe-only | +| `bgagent pending` | Unified queue — tool and event gates differ in trigger context | +| `bgagent rules eval --fixture` | Local rule evaluation (Phase 3) | +| Submit preview | Governance preview from resolved rules (future) | + +Async `require_approval` after `pr_created` must state that the PR already exists. + +--- + +## 8. Phased delivery + +| Phase | Scope | Outcome | +|-------|-------|---------| +| 0 | Catalog + observe_only + PolicyDecisionEvent | "Would have fired" in watch stream | +| 1 | Async notify/fan-out + webhook | Ping on PR/cost without new HITL | +| 2 | Sync checkpoints + enforce | Plan review before code | +| 3 | Registry-native event-rule-pack | Org-wide versioned rollout | +| 4 | Advanced aggregates + async cancel | Operator automation | + +--- + +## 9. Out of scope + +- Replacing tool Cedar with event rules +- Cedar-on-events (rejected — see §10; declarative matchers are permanent) +- Stream-only HITL (race with fast agents) +- EventBridge as primary internal bus +- Separate approve commands for event vs tool gates + +--- + +## 10. Open questions + +| Question | Resolution | +|----------|------------| +| Rule language | **Resolved: declarative field matchers are the permanent language; Cedar-on-events is rejected.** Cedar is an *authorization* language (`principal-action-resource-context` → permit/forbid) with no aggregation — it cannot express `cost_usd_gte` / `turn_count_gte`, and event actions (`notify`/`escalate`/`cancel_task`/`inject_nudge`) are ECA automation, not authorization decisions. A Cedar port would still need a bespoke action layer, and it would add a third Cedar runtime alongside the two we already must bump in lockstep (`cedar-wasm`, `cedarpy`) for zero gain. The two-plane split stands: Cedar governs tool execution (fail-closed); the declarative matcher governs events. The matcher already has cross-language parity, schema validation, and fixture parity tests. | +| Checkpoint trust | Pipeline-emitted only | +| Scope algebra (tool + event overlap) | Idempotency key; document in runbooks | diff --git a/docs/src/content/docs/architecture/Event-governance.md b/docs/src/content/docs/architecture/Event-governance.md new file mode 100644 index 00000000..51fbc761 --- /dev/null +++ b/docs/src/content/docs/architecture/Event-governance.md @@ -0,0 +1,174 @@ +--- +title: Event governance +--- + +# Event-Driven Governance and Actions + +> **Status:** Active design (issue #230) +> **Last updated:** 2026-06-12 + +--- + +## Executive summary + +Today, human-in-the-loop (HITL) and most governance controls are **synchronous and tool-centric**: Cedar policies in `PreToolUse` gate Bash, Write, Read, etc. Operators configure bash patterns and tool shapes — not semantic moments like "plan ready", "PR opened", or "cumulative cost exceeded $25". + +Meanwhile, **observability and notifications are already event-driven** via `TaskEventsTable` and `FanOutConsumer`, but that plane cannot prevent side effects unless something blocked earlier on the hot path. + +This document defines a unified **Event Governance** layer: a normative event catalog, declarative **event rules** (condition → action), **sync** (in-agent, can block) vs **async** (stream consumer, react only) evaluation modes, **registry-native configuration** (versioned `event-rule-pack` assets pinned by blueprints), and **UX as a first-class requirement** (`bgagent submit` governance preview, unified `bgagent pending` for event- and tool-sourced approvals). Tool-level Cedar HITL remains the fail-closed safety net for execution. + +**Related:** [CEDAR_HITL_GATES.md](/sample-autonomous-cloud-coding-agents/architecture/cedar-hitl-gates), [INTERACTIVE_AGENTS.md](/sample-autonomous-cloud-coding-agents/architecture/interactive-agents), [ORCHESTRATOR.md](/sample-autonomous-cloud-coding-agents/architecture/orchestrator), [SECURITY.md](/sample-autonomous-cloud-coding-agents/architecture/security). + +--- + +## 1. Two planes, one catalog + +### 1.1 Normative event catalog + +Stable names and schemas for lifecycle, execution, milestone, checkpoint, and policy events. Machine-readable catalog at `contracts/event-catalog/v1.json` with additive versioning (`catalog_version`). + +### 1.2 Event rules + +Each rule has: + +| Field | Description | +|-------|-------------| +| `id` | Stable rule identifier | +| `on` | Event name (top-level `event_type` or milestone/checkpoint name) | +| `when` | Optional field matchers and aggregate conditions | +| `action` | `require_approval`, `notify`, `escalate`, `cancel_task`, `inject_nudge`, `observe_only` | +| `mode` | `observe_only` or `enforce` | +| `evaluation` | `sync` (in-agent) or `async` (stream consumer) | + +Schema: `agent/event-rules/schema.json`. + +### 1.3 Sync evaluation + +In-agent at pipeline checkpoints (`checkpoint:before_execution`, `checkpoint:before_open_pr`, etc.). Same latency class as Cedar; can transition to `AWAITING_APPROVAL` when `mode: enforce` and `action: require_approval`. + +### 1.4 Async evaluation + +`TaskEventsTable` stream consumer (folded into `FanOutConsumer` — no third Lambda until Kinesis migration). Tens–hundreds of ms; must not imply blocking unless UX is explicit. + +### 1.5 Precedence + +1. Tool Cedar **hard-deny** always wins. +2. Async never overrides sync deny. +3. Composable with existing `TaskApprovalsTable` / `bgagent approve` / `deny`. +4. Idempotency key: `(task_id, rule_id, correlation_id)`. + +--- + +## 2. PolicyDecisionEvent + +Unified audit schema at `contracts/policy-decision/schema.json`. Top-level `event_type: policy_decision` on `TaskEventsTable`. + +| Field | Purpose | +|-------|---------| +| `decision` | `allow`, `deny`, `require_approval`, `observe` | +| `source` | `cedar_tool`, `event_rule`, `submission` | +| `enforcement_mode` | `observe_only`, `enforce` | +| `rule_id`, `rule_pack_id` | Rule attribution | +| `event_type`, `correlation_id` | Trigger context | +| `matching_rule_ids` | Cedar parity where applicable | +| `reason`, `severity`, `timeout_s` | HITL metadata | + +--- + +## 3. Configuration + +### 3.1 Inline (Phase 0–2) + +Blueprint `security.eventRules` — array of rules persisted in RepoTable, frozen on TaskRecord at submit time (same pattern as `approval_gate_cap`). + +### 3.2 Registry-native (Phase 3) + +Blueprint `security.eventRulePack: { id, version }` resolves from agent asset registry. Inline rules merge as overrides; pack rules take precedence unless overridden per-rule. + +Packs are authored under `agent/event-rules/packs/*.json`, alongside `agent/workflows/` — one asset tree, one authoring UX, validated against `agent/event-rules/schema.json`. Today the CDK resolver bundles these at build time (`resolveEventRules`); its interface matches the future `RegistryService.resolve('event-rule-pack')`, so the registry swap is local to the resolver. The agent runtime never loads pack files — it receives already-resolved `event_rules` frozen on the TaskRecord at submit time, which keeps the sync/async evaluators reading one source. + +--- + +## 4. Checkpoints + +Pipeline-owned, not agent-declared: + +| Checkpoint | When | +|----------|------| +| `checkpoint:before_execution` | After repo setup, before agent loop | +| `checkpoint:before_open_pr` | Before PR creation step | + +Emitted as `agent_milestone` with `metadata.checkpoint`. + +--- + +## 5. Data model extensions + +### TaskApprovalsTable + +| Field | Values | +|-------|--------| +| `source` | `tool` (default), `event` | +| `event_type` | Trigger event when `source=event` | +| `checkpoint` | Checkpoint name when applicable | +| `rule_pack_id`, `rule_id` | Rule attribution | + +GSI `user_id-status-index` projects new fields for `bgagent pending`. + +### TaskRecord + +| Field | Purpose | +|-------|---------| +| `event_rules` | Frozen rules at submit time | +| `event_rule_pack_id`, `event_rule_pack_version` | Registry pin (Phase 3) | + +--- + +## 6. Stream consumer strategy + +`FanOutConsumer` evaluates async event rules before channel dispatch. No third DDB stream consumer until Kinesis migration (see `task-events-table.ts` architectural note). + +--- + +## 7. UX + +| Surface | Behavior | +|---------|----------| +| `bgagent watch` | Renders `policy_decision` events; `[observe]` prefix for observe-only | +| `bgagent pending` | Unified queue — tool and event gates differ in trigger context | +| `bgagent rules eval --fixture` | Local rule evaluation (Phase 3) | +| Submit preview | Governance preview from resolved rules (future) | + +Async `require_approval` after `pr_created` must state that the PR already exists. + +--- + +## 8. Phased delivery + +| Phase | Scope | Outcome | +|-------|-------|---------| +| 0 | Catalog + observe_only + PolicyDecisionEvent | "Would have fired" in watch stream | +| 1 | Async notify/fan-out + webhook | Ping on PR/cost without new HITL | +| 2 | Sync checkpoints + enforce | Plan review before code | +| 3 | Registry-native event-rule-pack | Org-wide versioned rollout | +| 4 | Advanced aggregates + async cancel | Operator automation | + +--- + +## 9. Out of scope + +- Replacing tool Cedar with event rules +- Cedar-on-events (rejected — see §10; declarative matchers are permanent) +- Stream-only HITL (race with fast agents) +- EventBridge as primary internal bus +- Separate approve commands for event vs tool gates + +--- + +## 10. Open questions + +| Question | Resolution | +|----------|------------| +| Rule language | **Resolved: declarative field matchers are the permanent language; Cedar-on-events is rejected.** Cedar is an *authorization* language (`principal-action-resource-context` → permit/forbid) with no aggregation — it cannot express `cost_usd_gte` / `turn_count_gte`, and event actions (`notify`/`escalate`/`cancel_task`/`inject_nudge`) are ECA automation, not authorization decisions. A Cedar port would still need a bespoke action layer, and it would add a third Cedar runtime alongside the two we already must bump in lockstep (`cedar-wasm`, `cedarpy`) for zero gain. The two-plane split stands: Cedar governs tool execution (fail-closed); the declarative matcher governs events. The matcher already has cross-language parity, schema validation, and fixture parity tests. | +| Checkpoint trust | Pipeline-emitted only | +| Scope algebra (tool + event overlap) | Idempotency key; document in runbooks |