agentctrl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ # Copyright 2026 Mohammad Abu Jafar
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """agentctrl — Central validation pipeline (standalone version).
16
+
17
+ Runtime Governance Model — Layer 1
18
+ ===================================
19
+ The pipeline evaluates every ActionProposal through 5 sequential decision
20
+ stages (autonomy → policy → authority → risk → conflict). Each stage can
21
+ short-circuit with BLOCK or ESCALATE. If all stages pass, the decision
22
+ is ALLOW.
23
+
24
+ The kill switch is an optional pre-gate callback (`kill_switch_fn`) so
25
+ the library works without platform dependencies. Fail-closed: any
26
+ unhandled exception during pipeline evaluation produces BLOCK.
27
+ """
28
+
29
+ import dataclasses
30
+ import logging
31
+ import time
32
+ from datetime import datetime, timezone
33
+ from typing import Callable, Awaitable
34
+
35
+ from .policy_engine import PolicyEngine, OPS
36
+ from .authority_graph import AuthorityGraphEngine
37
+ from .risk_engine import RiskEngine
38
+ from .conflict_detector import ConflictDetector
39
+ from .types import ActionProposal, PipelineHooks, PipelineStageResult, RuntimeDecisionRecord
40
+
41
+ logger = logging.getLogger("agentctrl.runtime")
42
+
43
+ KillSwitchFn = Callable[[str], Awaitable[tuple[bool, str]]]
44
+
45
+
46
+ class RuntimeGateway:
47
+ """
48
+ Sequential decision pipeline — the core of agentctrl's governance model.
49
+
50
+ Pipeline stages (sequential, each can short-circuit):
51
+ Pre-gate: Kill switch / emergency override (optional callback)
52
+ Pre-gate: Rate limiting (optional)
53
+ Stage 1: Autonomy check (with conditional scopes)
54
+ Stage 2: Policy validation
55
+ Stage 3: Authority resolution
56
+ Stage 4: Risk assessment
57
+ Stage 5: Conflict detection
58
+ Terminal: ALLOW / ESCALATE / BLOCK
59
+
60
+ Side effects are handled via optional PipelineHooks.
61
+ When hooks are None the pipeline runs as pure evaluation.
62
+ Fail-closed: any unhandled exception produces BLOCK.
63
+ """
64
+
65
+ AUTONOMY_LEVEL_ACTIONS = {
66
+ 0: [], # Suggest only — no actions permitted
67
+ 1: [], # All actions require human approval
68
+ 2: ["read", "query", "search", "summarize", "extract", "validate", "classify",
69
+ "invoice", "data", "report", "audit"], # routine ops; policy engine further governs
70
+ 3: ["*"], # All actions within policy bounds
71
+ 4: ["*"], # Full autonomy
72
+ 5: ["*"], # Full independence — unrestricted within platform
73
+ }
74
+
75
+ def __init__(
76
+ self,
77
+ policy_engine: PolicyEngine | None = None,
78
+ authority_engine: AuthorityGraphEngine | None = None,
79
+ hooks: PipelineHooks | None = None,
80
+ kill_switch_fn: KillSwitchFn | None = None,
81
+ autonomy_scopes: dict[int, list] | None = None,
82
+ risk_engine: RiskEngine | None = None,
83
+ rate_limits: list[dict] | None = None,
84
+ ):
85
+ self.policy_engine = policy_engine or PolicyEngine()
86
+ self.authority_engine = authority_engine or AuthorityGraphEngine()
87
+ self.risk_engine = risk_engine or RiskEngine()
88
+ self.conflict_detector = ConflictDetector()
89
+ self.hooks = hooks
90
+ self._kill_switch_fn = kill_switch_fn
91
+ self._rate_limits = rate_limits or []
92
+ self._rate_buckets: dict[str, list[float]] = {}
93
+ if autonomy_scopes is not None:
94
+ self._autonomy_scopes = autonomy_scopes
95
+ else:
96
+ self._autonomy_scopes = self.AUTONOMY_LEVEL_ACTIONS
97
+
98
+ async def validate(self, proposal: ActionProposal) -> dict:
99
+ """Run the sequential decision pipeline (pre-gate + 5 stages).
100
+
101
+ Fail-closed: any unhandled exception during evaluation produces
102
+ a BLOCK decision rather than propagating to the caller.
103
+ """
104
+ try:
105
+ return await self._run_pipeline(proposal)
106
+ except Exception as exc:
107
+ logger.error("Pipeline error for %s (agent=%s): %s",
108
+ proposal.action_type, proposal.agent_id, exc)
109
+ return {
110
+ "proposal_id": proposal.proposal_id,
111
+ "agent_id": proposal.agent_id,
112
+ "action_type": proposal.action_type,
113
+ "decision": "BLOCK",
114
+ "reason": f"Governance pipeline error — blocked for safety. Error: {str(exc)[:200]}",
115
+ "risk_score": 0.0,
116
+ "risk_level": "CRITICAL",
117
+ "escalated_to": None,
118
+ "pipeline": [],
119
+ "decided_at": datetime.now(timezone.utc).isoformat(),
120
+ }
121
+
122
+ async def _run_pipeline(self, proposal: ActionProposal) -> dict:
123
+ """Internal pipeline execution — exceptions caught by validate()."""
124
+ pid = proposal.proposal_id
125
+ logger.info("pipeline.start proposal=%s action=%s agent=%s",
126
+ pid, proposal.action_type, proposal.agent_id)
127
+ stages: list[PipelineStageResult] = []
128
+
129
+ # ── Pre-gate: Kill Switch ────────────────────────────────────────────
130
+ if self._kill_switch_fn:
131
+ ks_paused, ks_reason = await self._kill_switch_fn(proposal.agent_id)
132
+ if ks_paused:
133
+ stages.append(PipelineStageResult("kill_switch", "BLOCK",
134
+ {"kill_switch_active": True}, ks_reason))
135
+ return self._make_decision(proposal, stages, "BLOCK", ks_reason, 0.0, "CRITICAL")
136
+
137
+ # ── Pre-gate: Rate Limiting ──────────────────────────────────────────
138
+ rate_result = await self._check_rate_limit(proposal)
139
+ if rate_result is not None:
140
+ stages.append(rate_result)
141
+ return self._make_decision(proposal, stages, "BLOCK", rate_result.reason, 0.0, "LOW")
142
+
143
+ # ── Stage 1: Autonomy Level Check ────────────────────────────────────
144
+ stage1 = await self._check_autonomy(proposal)
145
+ stages.append(stage1)
146
+ if stage1.status == "BLOCK":
147
+ return self._make_decision(proposal, stages, "BLOCK", stage1.reason, 0.0, "LOW")
148
+ if stage1.status == "ESCALATE":
149
+ risk = await self.risk_engine.score(proposal)
150
+ stages.append(PipelineStageResult("risk_scoring", "INFO",
151
+ {"risk_score": risk.score, "risk_level": risk.level}))
152
+ return self._make_decision(proposal, stages, "ESCALATE", stage1.reason,
153
+ risk.score, risk.level, escalated_to="approver_required")
154
+
155
+ # ── Stage 2: Policy Validation ───────────────────────────────────────
156
+ stage2 = await self.policy_engine.validate(proposal)
157
+ stages.append(stage2)
158
+ if stage2.status in ("BLOCK", "ESCALATE"):
159
+ risk = await self.risk_engine.score(proposal)
160
+ return self._make_decision(proposal, stages, stage2.status, stage2.reason,
161
+ risk.score, risk.level)
162
+
163
+ # ── Stage 3: Authority Graph Resolution ──────────────────────────────
164
+ stage3 = await self.authority_engine.resolve(proposal)
165
+ stages.append(stage3)
166
+ if stage3.status in ("BLOCK", "ESCALATE"):
167
+ risk = await self.risk_engine.score(proposal)
168
+ escalated_to = stage3.details.get("escalate_to")
169
+ return self._make_decision(proposal, stages, stage3.status, stage3.reason,
170
+ risk.score, risk.level, escalated_to=escalated_to)
171
+
172
+ # ── Stage 4: Risk Scoring ────────────────────────────────────────────
173
+ risk = await self.risk_engine.score(proposal)
174
+ stage4 = PipelineStageResult(
175
+ stage="risk_scoring",
176
+ status="ESCALATE" if risk.level in ("HIGH", "CRITICAL") else "PASS",
177
+ details={"risk_score": risk.score, "risk_level": risk.level, "factors": risk.factors},
178
+ reason=f"Risk level: {risk.level} (score: {risk.score:.2f})",
179
+ )
180
+ stages.append(stage4)
181
+ if stage4.status == "ESCALATE":
182
+ return self._make_decision(proposal, stages, "ESCALATE", stage4.reason,
183
+ risk.score, risk.level)
184
+
185
+ # ── Stage 5: Conflict Detection ──────────────────────────────────────
186
+ stage5 = await self.conflict_detector.check(proposal)
187
+ stages.append(stage5)
188
+ if stage5.status == "BLOCK":
189
+ return self._make_decision(proposal, stages, "BLOCK", stage5.reason, risk.score, risk.level)
190
+ if stage5.status == "ESCALATE":
191
+ return self._make_decision(proposal, stages, "ESCALATE", stage5.reason, risk.score, risk.level)
192
+
193
+ # ── Terminal: All stages passed → ALLOW ──────────────────────────────
194
+ reason = f"All validation stages passed. Action '{proposal.action_type}' approved for execution."
195
+ return self._make_decision(proposal, stages, "ALLOW", reason, risk.score, risk.level)
196
+
197
+ async def _check_autonomy(self, proposal: ActionProposal) -> PipelineStageResult:
198
+ level = proposal.autonomy_level
199
+ action = proposal.action_type.split(".")[0] if "." in proposal.action_type else proposal.action_type
200
+
201
+ if level == 0:
202
+ return PipelineStageResult("autonomy_check", "BLOCK", {"autonomy_level": level},
203
+ "Level 0 agents cannot execute any actions (suggest-only mode).")
204
+ if level == 1:
205
+ return PipelineStageResult("autonomy_check", "ESCALATE", {"autonomy_level": level},
206
+ "Level 1 agents require explicit human approval for all actions.")
207
+
208
+ permitted = self._autonomy_scopes.get(level, [])
209
+
210
+ # Conditional scopes: entries can be structured dicts with conditions and trust thresholds
211
+ if isinstance(permitted, list):
212
+ for entry in permitted:
213
+ if isinstance(entry, dict):
214
+ matched = self._check_conditional_scope(entry, proposal, action)
215
+ if matched is True:
216
+ return PipelineStageResult(
217
+ "autonomy_check", "PASS",
218
+ {"autonomy_level": level, "action": action, "scope_type": "conditional"},
219
+ f"Action '{action}' matched conditional autonomy scope.",
220
+ )
221
+ if matched == "trust_insufficient":
222
+ trust_ctx = getattr(proposal, "trust_context", None) or {}
223
+ return PipelineStageResult(
224
+ "autonomy_check", "ESCALATE",
225
+ {"autonomy_level": level, "action": action,
226
+ "trust_total": trust_ctx.get("total_actions", 0),
227
+ "scope_type": "trust_gated"},
228
+ (f"Action '{action}' is trust-gated. Agent has not met the required "
229
+ f"trust threshold for autonomous execution."),
230
+ )
231
+ elif isinstance(entry, str):
232
+ if entry == "*" or entry == action:
233
+ return PipelineStageResult(
234
+ "autonomy_check", "PASS",
235
+ {"autonomy_level": level, "action": action},
236
+ f"Action '{action}' is within pre-approved scope for Level {level} agents.",
237
+ )
238
+
239
+ if isinstance(permitted, list) and all(isinstance(e, str) for e in permitted):
240
+ if "*" not in permitted and action not in permitted:
241
+ return PipelineStageResult("autonomy_check", "ESCALATE",
242
+ {"autonomy_level": level, "action": action, "permitted": permitted},
243
+ f"Action '{action}' is not in pre-approved scope for Level {level} agents.")
244
+ return PipelineStageResult("autonomy_check", "PASS",
245
+ {"autonomy_level": level, "action": action},
246
+ f"Action '{action}' is within pre-approved scope for Level {level} agents.")
247
+
248
+ return PipelineStageResult("autonomy_check", "ESCALATE",
249
+ {"autonomy_level": level, "action": action},
250
+ f"Action '{action}' is not in pre-approved scope for Level {level} agents.")
251
+
252
+ def _check_conditional_scope(self, scope_entry: dict, proposal: ActionProposal, action: str):
253
+ """Evaluate a conditional autonomy scope entry.
254
+
255
+ Returns True if scope matches, "trust_insufficient" if action type matches
256
+ but trust is too low, or False if action type doesn't match.
257
+ """
258
+ scope_action = scope_entry.get("action_type", "")
259
+ if scope_action != "*" and scope_action != action:
260
+ if not (scope_action.endswith(".*") and action.startswith(scope_action[:-2])):
261
+ return False
262
+
263
+ conditions = scope_entry.get("conditions", [])
264
+ for cond in conditions:
265
+ param = cond.get("param", "")
266
+ op = cond.get("op", "lte")
267
+ value = cond.get("value")
268
+ actual = proposal.action_params.get(param)
269
+ if actual is None:
270
+ return False
271
+ op_fn = OPS.get(op)
272
+ if op_fn:
273
+ try:
274
+ if not op_fn(actual, value):
275
+ return False
276
+ except (TypeError, ValueError):
277
+ return False
278
+
279
+ trust_threshold = scope_entry.get("trust_threshold")
280
+ if trust_threshold is not None:
281
+ trust_ctx = getattr(proposal, "trust_context", None) or {}
282
+ total_actions = trust_ctx.get("total_actions", 0)
283
+ if total_actions < trust_threshold:
284
+ return "trust_insufficient"
285
+
286
+ return True
287
+
288
+ async def _check_rate_limit(self, proposal: ActionProposal) -> PipelineStageResult | None:
289
+ """Pre-gate: check rate limits with burst detection and rate_pressure signal."""
290
+ if not self._rate_limits:
291
+ return None
292
+ agent_id = proposal.agent_id
293
+ now = time.monotonic()
294
+ max_pressure = 0.0
295
+
296
+ for rl in self._rate_limits:
297
+ target_type = rl.get("target_type", "global")
298
+ target_id = rl.get("target_id", "*")
299
+ max_req = rl.get("max_requests", 100)
300
+ window = rl.get("window_seconds", 60)
301
+
302
+ if target_type == "agent" and target_id != "*" and target_id != agent_id:
303
+ continue
304
+ if target_type == "action_type" and target_id != proposal.action_type:
305
+ continue
306
+
307
+ key = f"rl:{target_type}:{target_id}"
308
+ bucket = self._rate_buckets.setdefault(key, [])
309
+ cutoff = now - window
310
+ self._rate_buckets[key] = [t for t in bucket if t > cutoff]
311
+
312
+ current_count = len(self._rate_buckets[key])
313
+ pressure = current_count / max(max_req, 1)
314
+ max_pressure = max(max_pressure, pressure)
315
+
316
+ if current_count >= max_req:
317
+ proposal.context["rate_pressure"] = round(min(pressure, 1.0), 3)
318
+ return PipelineStageResult(
319
+ "rate_limit", "BLOCK",
320
+ {"limit": max_req, "window_seconds": window, "rate_pressure": round(pressure, 3)},
321
+ f"Rate limit exceeded: {max_req} requests per {window}s for {target_type}:{target_id}.",
322
+ )
323
+
324
+ # Burst detection: >50% of limit consumed in last 5 seconds
325
+ burst_window = min(5.0, window)
326
+ burst_cutoff = now - burst_window
327
+ burst_count = sum(1 for t in self._rate_buckets[key] if t > burst_cutoff)
328
+ burst_threshold = max(max_req * 0.5, 3)
329
+ if burst_count >= burst_threshold:
330
+ proposal.context["rate_pressure"] = round(min(pressure, 1.0), 3)
331
+ return PipelineStageResult(
332
+ "rate_limit", "BLOCK",
333
+ {"limit": max_req, "window_seconds": window, "burst": True,
334
+ "burst_count": burst_count, "burst_window": burst_window,
335
+ "rate_pressure": round(pressure, 3)},
336
+ (f"Burst detected: {burst_count} requests in {burst_window:.0f}s "
337
+ f"(threshold: {burst_threshold:.0f}) for {target_type}:{target_id}."),
338
+ )
339
+
340
+ self._rate_buckets[key].append(now)
341
+
342
+ if max_pressure > 0:
343
+ proposal.context["rate_pressure"] = round(min(max_pressure, 1.0), 3)
344
+ return None
345
+
346
+ def _make_decision(self, proposal, stages, decision, reason, risk_score, risk_level, escalated_to=None):
347
+ pipeline_dict = [
348
+ {"stage": s.stage, "status": s.status, "details": s.details, "reason": s.reason}
349
+ for s in stages
350
+ ]
351
+ logger.info("pipeline.decision proposal=%s decision=%s risk=%s/%s stages_evaluated=%d action=%s agent=%s",
352
+ proposal.proposal_id, decision, risk_level, f"{risk_score:.2f}",
353
+ len(stages), proposal.action_type, proposal.agent_id)
354
+
355
+ if self.hooks:
356
+ if self.hooks.on_decision:
357
+ try:
358
+ self.hooks.on_decision(decision, proposal, risk_score, risk_level)
359
+ except Exception:
360
+ pass
361
+ if decision == "BLOCK" and self.hooks.on_block_alert:
362
+ try:
363
+ import asyncio
364
+ asyncio.ensure_future(self.hooks.on_block_alert(proposal, reason, risk_level))
365
+ except Exception:
366
+ pass
367
+ if self.hooks.on_broadcast:
368
+ try:
369
+ self.hooks.on_broadcast({
370
+ "type": "governance.decision",
371
+ "agent_id": proposal.agent_id,
372
+ "action_type": proposal.action_type,
373
+ "decision": decision,
374
+ "reason": reason,
375
+ "risk_score": risk_score,
376
+ "risk_level": risk_level,
377
+ })
378
+ except Exception:
379
+ pass
380
+
381
+ record = RuntimeDecisionRecord(
382
+ proposal_id=proposal.proposal_id,
383
+ agent_id=proposal.agent_id,
384
+ action_type=proposal.action_type,
385
+ action_params=proposal.action_params,
386
+ pipeline_stages=stages,
387
+ decision=decision,
388
+ reason=reason,
389
+ risk_score=risk_score,
390
+ risk_level=risk_level,
391
+ escalated_to=escalated_to,
392
+ decided_at=datetime.now(timezone.utc),
393
+ )
394
+
395
+ result = dataclasses.asdict(record)
396
+ result["pipeline"] = pipeline_dict
397
+ result["decided_at"] = record.decided_at.isoformat()
398
+
399
+ # Preserve cross-cutting signals in decision record
400
+ consequence_class = getattr(proposal, "consequence_class", None)
401
+ if consequence_class:
402
+ result["consequence_class"] = consequence_class
403
+ evidence = getattr(proposal, "evidence", None)
404
+ if evidence:
405
+ result["evidence"] = evidence
406
+ input_confidence = getattr(proposal, "input_confidence", None)
407
+ if input_confidence is not None:
408
+ result["input_confidence"] = input_confidence
409
+ rate_pressure = proposal.context.get("rate_pressure")
410
+ if rate_pressure is not None:
411
+ result["rate_pressure"] = rate_pressure
412
+
413
+ if self.hooks and self.hooks.on_audit:
414
+ try:
415
+ self.hooks.on_audit(result)
416
+ except Exception:
417
+ pass
418
+
419
+ return result
agentctrl/types.py ADDED
@@ -0,0 +1,92 @@
1
+ # Copyright 2026 Mohammad Abu Jafar
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """agentctrl — Shared data types for the governance pipeline."""
16
+
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from typing import Callable, Awaitable
20
+ from uuid import uuid4
21
+
22
+
23
+ CONSEQUENCE_CLASSES = {"reversible", "partially_reversible", "irreversible"}
24
+
25
+
26
+ @dataclass
27
+ class ActionProposal:
28
+ """Structured proposal submitted by an agent for governance evaluation.
29
+
30
+ trust_context expected shape (optional — no-op when empty):
31
+ {"total_actions": int, "success_rate": float, "trust_balance": float}
32
+ """
33
+ agent_id: str
34
+ action_type: str
35
+ action_params: dict = field(default_factory=dict)
36
+ workflow_id: str | None = None
37
+ context: dict = field(default_factory=dict)
38
+ autonomy_level: int = 1
39
+ proposal_id: str = field(default_factory=lambda: str(uuid4()))
40
+ submitted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
41
+ trust_context: dict = field(default_factory=dict)
42
+
43
+ # Cross-cutting signals — optional, backward-compatible.
44
+ consequence_class: str | None = None # "reversible" | "partially_reversible" | "irreversible"
45
+ evidence: dict | None = None # {type: str, reference: str, timestamp: str}
46
+ input_confidence: float | None = None # 0.0–1.0; None treated as 0.7 default by risk engine
47
+
48
+
49
+ @dataclass
50
+ class PipelineStageResult:
51
+ """Result from a single pipeline stage."""
52
+ stage: str
53
+ status: str # PASS | FAIL | ESCALATE | BLOCK
54
+ details: dict = field(default_factory=dict)
55
+ reason: str = ""
56
+
57
+
58
+ @dataclass
59
+ class PipelineHooks:
60
+ """Optional callbacks for pipeline side effects (metrics, WS, alerting).
61
+
62
+ When None, the side effect is skipped — enabling standalone library use
63
+ without platform dependencies.
64
+ """
65
+ on_decision: Callable[[str, "ActionProposal", float, str], None] | None = None
66
+ on_block_alert: Callable[["ActionProposal", str, str], Awaitable[None]] | None = None
67
+ on_broadcast: Callable[[dict], None] | None = None
68
+ on_audit: Callable[[dict], None] | None = None
69
+
70
+
71
+ @dataclass
72
+ class EscalationTarget:
73
+ """Structured escalation target (replaces bare string `escalated_to`)."""
74
+ target_type: str # "agent" | "role" | "group"
75
+ target_id: str
76
+ reason: str = ""
77
+
78
+
79
+ @dataclass
80
+ class RuntimeDecisionRecord:
81
+ """Complete decision record produced by the pipeline."""
82
+ proposal_id: str
83
+ agent_id: str
84
+ action_type: str
85
+ action_params: dict
86
+ pipeline_stages: list[PipelineStageResult]
87
+ decision: str # ALLOW | ESCALATE | BLOCK
88
+ reason: str
89
+ risk_score: float
90
+ risk_level: str
91
+ escalated_to: "EscalationTarget | str | None" = None
92
+ decided_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))