agec 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.
- agec/__init__.py +44 -0
- agec/audit.py +114 -0
- agec/core.py +195 -0
- agec/guard.py +129 -0
- agec/policies.py +79 -0
- agec/validator.py +145 -0
- agec-0.1.0.dist-info/METADATA +206 -0
- agec-0.1.0.dist-info/RECORD +10 -0
- agec-0.1.0.dist-info/WHEEL +5 -0
- agec-0.1.0.dist-info/top_level.txt +1 -0
agec/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""AGEC — Authorized Governance Execution Context.
|
|
2
|
+
|
|
3
|
+
A pre-execution governance layer for AI agents. Every agent action is
|
|
4
|
+
validated for intent, semantic context, execution path, and data
|
|
5
|
+
processing permissions before the tool call is allowed to run.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from agec import guard
|
|
10
|
+
|
|
11
|
+
@guard(
|
|
12
|
+
intent="send_email",
|
|
13
|
+
purpose="customer_support",
|
|
14
|
+
allowed_tools=["gmail.send"],
|
|
15
|
+
)
|
|
16
|
+
def send_email() -> str:
|
|
17
|
+
return "Email sent."
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .audit import AuditEvent, AuditLog
|
|
21
|
+
from .core import AGEC, AGECStatus, AGECTransitionError, DataPermissions, ExecutionPath, Intent
|
|
22
|
+
from .guard import AGECBlockedError, guard
|
|
23
|
+
from .policies import DEFAULT_LEGAL_BASES, Policy
|
|
24
|
+
from .validator import AGECValidator, ValidationResult, validate
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AGEC",
|
|
30
|
+
"AGECStatus",
|
|
31
|
+
"AGECTransitionError",
|
|
32
|
+
"AGECBlockedError",
|
|
33
|
+
"AGECValidator",
|
|
34
|
+
"AuditEvent",
|
|
35
|
+
"AuditLog",
|
|
36
|
+
"DEFAULT_LEGAL_BASES",
|
|
37
|
+
"DataPermissions",
|
|
38
|
+
"ExecutionPath",
|
|
39
|
+
"Intent",
|
|
40
|
+
"Policy",
|
|
41
|
+
"ValidationResult",
|
|
42
|
+
"guard",
|
|
43
|
+
"validate",
|
|
44
|
+
]
|
agec/audit.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Audit logging for AGEC governance decisions.
|
|
2
|
+
|
|
3
|
+
Records every validation allow/deny event with a timestamp and
|
|
4
|
+
structured metadata. Events can be persisted to and loaded from
|
|
5
|
+
JSON files for replay and compliance auditing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AuditEvent:
|
|
19
|
+
"""A single governance decision recorded by AGEC.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
agec_id: UUID of the AGEC context that produced this event.
|
|
23
|
+
event_type: Dot-separated event type, e.g. ``validation.allowed``.
|
|
24
|
+
message: Human-readable description of the outcome.
|
|
25
|
+
metadata: Arbitrary structured data attached to the event.
|
|
26
|
+
timestamp: ISO-8601 UTC timestamp of when the event was recorded.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
agec_id: str
|
|
30
|
+
event_type: str
|
|
31
|
+
message: str
|
|
32
|
+
metadata: dict[str, Any]
|
|
33
|
+
timestamp: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuditLog:
|
|
37
|
+
"""Append-only log of :class:`AuditEvent` records.
|
|
38
|
+
|
|
39
|
+
Events are stored in memory and can optionally be persisted to a
|
|
40
|
+
newline-delimited JSON file (one JSON object per line) for replay
|
|
41
|
+
and compliance use-cases.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
log = AuditLog()
|
|
46
|
+
log.record("abc-123", "validation.allowed", "Passed.", {"intent": "send_email"})
|
|
47
|
+
log.save_json("audit.jsonl")
|
|
48
|
+
restored = AuditLog.load_json("audit.jsonl")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.events: list[AuditEvent] = []
|
|
53
|
+
|
|
54
|
+
def record(
|
|
55
|
+
self,
|
|
56
|
+
agec_id: str,
|
|
57
|
+
event_type: str,
|
|
58
|
+
message: str,
|
|
59
|
+
metadata: dict[str, Any] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Append a new event to the log.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
agec_id: UUID of the originating AGEC context.
|
|
65
|
+
event_type: Dot-separated event identifier.
|
|
66
|
+
message: Human-readable outcome description.
|
|
67
|
+
metadata: Optional structured data to attach.
|
|
68
|
+
"""
|
|
69
|
+
self.events.append(
|
|
70
|
+
AuditEvent(
|
|
71
|
+
agec_id=agec_id,
|
|
72
|
+
event_type=event_type,
|
|
73
|
+
message=message,
|
|
74
|
+
metadata=metadata or {},
|
|
75
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def to_list(self) -> list[dict[str, Any]]:
|
|
80
|
+
"""Return all events as a list of plain dicts."""
|
|
81
|
+
return [asdict(event) for event in self.events]
|
|
82
|
+
|
|
83
|
+
def save_json(self, path: str | Path, *, append: bool = False) -> None:
|
|
84
|
+
"""Persist events to a newline-delimited JSON file.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
path: File path to write to.
|
|
88
|
+
append: If ``True``, append to an existing file instead of
|
|
89
|
+
overwriting it. Useful for long-running processes.
|
|
90
|
+
"""
|
|
91
|
+
mode = "a" if append else "w"
|
|
92
|
+
with open(path, mode, encoding="utf-8") as fh:
|
|
93
|
+
for event in self.events:
|
|
94
|
+
fh.write(json.dumps(asdict(event), ensure_ascii=False) + "\n")
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def load_json(cls, path: str | Path) -> "AuditLog":
|
|
98
|
+
"""Load events from a previously saved newline-delimited JSON file.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path: File path to read from.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A new :class:`AuditLog` pre-populated with the stored events.
|
|
105
|
+
"""
|
|
106
|
+
log = cls()
|
|
107
|
+
with open(path, encoding="utf-8") as fh:
|
|
108
|
+
for line in fh:
|
|
109
|
+
line = line.strip()
|
|
110
|
+
if not line:
|
|
111
|
+
continue
|
|
112
|
+
data = json.loads(line)
|
|
113
|
+
log.events.append(AuditEvent(**data))
|
|
114
|
+
return log
|
agec/core.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Core AGEC data models and lifecycle state machine.
|
|
2
|
+
|
|
3
|
+
AGEC (Authorized Governance Execution Context) is the central object
|
|
4
|
+
passed through the governance pipeline. It captures intent, execution
|
|
5
|
+
path, data permissions, and lifecycle state for a single agent action
|
|
6
|
+
request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AGECStatus(str, Enum):
|
|
20
|
+
"""Lifecycle states for an :class:`AGEC` context.
|
|
21
|
+
|
|
22
|
+
The valid transition graph is::
|
|
23
|
+
|
|
24
|
+
INACTIVE → AWAITING_VALIDATION → ACTIVE → EXECUTING → COMPLETED
|
|
25
|
+
↘ SUSPENDED
|
|
26
|
+
↘ CANCELLED (expired)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
INACTIVE = "inactive"
|
|
30
|
+
AWAITING_VALIDATION = "awaiting_validation"
|
|
31
|
+
ACTIVE = "active"
|
|
32
|
+
EXECUTING = "executing"
|
|
33
|
+
COMPLETED = "completed"
|
|
34
|
+
SUSPENDED = "suspended"
|
|
35
|
+
CANCELLED = "cancelled"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Allowed (from_state, to_state) transitions.
|
|
39
|
+
_ALLOWED_TRANSITIONS: frozenset[tuple[AGECStatus, AGECStatus]] = frozenset(
|
|
40
|
+
{
|
|
41
|
+
(AGECStatus.INACTIVE, AGECStatus.AWAITING_VALIDATION),
|
|
42
|
+
(AGECStatus.AWAITING_VALIDATION, AGECStatus.ACTIVE),
|
|
43
|
+
(AGECStatus.AWAITING_VALIDATION, AGECStatus.SUSPENDED),
|
|
44
|
+
(AGECStatus.AWAITING_VALIDATION, AGECStatus.CANCELLED),
|
|
45
|
+
(AGECStatus.ACTIVE, AGECStatus.EXECUTING),
|
|
46
|
+
(AGECStatus.ACTIVE, AGECStatus.SUSPENDED),
|
|
47
|
+
(AGECStatus.ACTIVE, AGECStatus.CANCELLED),
|
|
48
|
+
(AGECStatus.EXECUTING, AGECStatus.COMPLETED),
|
|
49
|
+
(AGECStatus.EXECUTING, AGECStatus.SUSPENDED),
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AGECTransitionError(RuntimeError):
|
|
55
|
+
"""Raised when an illegal status transition is attempted."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Intent:
|
|
60
|
+
"""Represents the declared intent of an agent action.
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
type: Intent identifier, e.g. ``"send_email"``.
|
|
64
|
+
source: Origin of the intent (default ``"user"``).
|
|
65
|
+
confidence: Model-assigned confidence score in ``[0.0, 1.0]``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
type: str
|
|
69
|
+
source: str = "user"
|
|
70
|
+
confidence: float = 1.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ExecutionPath:
|
|
75
|
+
"""Ordered sequence of tools that will be invoked.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
path_id: Human-readable identifier for this path.
|
|
79
|
+
steps: Ordered list of tool identifiers to be executed.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
path_id: str
|
|
83
|
+
steps: list[str]
|
|
84
|
+
|
|
85
|
+
def deterministic_hash(self) -> str:
|
|
86
|
+
"""Return a SHA-256 hex digest of the ordered tool list.
|
|
87
|
+
|
|
88
|
+
The hash is deterministic for the same sequence of steps and
|
|
89
|
+
can be stored in audit logs for replay verification.
|
|
90
|
+
"""
|
|
91
|
+
raw = "|".join(self.steps)
|
|
92
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class DataPermissions:
|
|
97
|
+
"""Data processing permissions attached to an agent action.
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
purpose: Processing purpose, e.g. ``"customer_support"``.
|
|
101
|
+
legal_basis: GDPR-aligned legal basis for processing.
|
|
102
|
+
allowed_operations: Operations permitted under this context.
|
|
103
|
+
data_categories: Categories of personal data involved.
|
|
104
|
+
retention_seconds: Optional maximum retention window in seconds.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
purpose: str
|
|
108
|
+
legal_basis: str
|
|
109
|
+
allowed_operations: list[str]
|
|
110
|
+
data_categories: list[str] = field(default_factory=list)
|
|
111
|
+
retention_seconds: int | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class AGEC:
|
|
116
|
+
"""Authorized Governance Execution Context.
|
|
117
|
+
|
|
118
|
+
The central governance object for a single agent action request.
|
|
119
|
+
It captures intent, execution path, data permissions, and lifecycle
|
|
120
|
+
state, and enforces valid status transitions.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
intent: The declared intent of the action.
|
|
124
|
+
context: Arbitrary key-value context (e.g. user_id, session_id).
|
|
125
|
+
execution_path: Ordered list of tools to be called.
|
|
126
|
+
data_permissions: Data processing permissions for this action.
|
|
127
|
+
agec_id: Auto-generated UUID; can be overridden for testing.
|
|
128
|
+
created_at: Unix timestamp of creation; auto-set.
|
|
129
|
+
ttl_seconds: Time-to-live in seconds (default 300).
|
|
130
|
+
status: Initial lifecycle state.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
intent: Intent
|
|
134
|
+
context: dict[str, Any]
|
|
135
|
+
execution_path: ExecutionPath
|
|
136
|
+
data_permissions: DataPermissions
|
|
137
|
+
agec_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
138
|
+
created_at: float = field(default_factory=time.time)
|
|
139
|
+
ttl_seconds: int = 300
|
|
140
|
+
status: AGECStatus = AGECStatus.AWAITING_VALIDATION
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Internal helpers
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _transition(self, target: AGECStatus) -> None:
|
|
147
|
+
"""Perform a guarded status transition.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
target: Desired next state.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
AGECTransitionError: If the transition is not permitted.
|
|
154
|
+
"""
|
|
155
|
+
if (self.status, target) not in _ALLOWED_TRANSITIONS:
|
|
156
|
+
raise AGECTransitionError(
|
|
157
|
+
f"Invalid AGEC transition: {self.status.value!r} → {target.value!r}"
|
|
158
|
+
)
|
|
159
|
+
self.status = target
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Lifecycle API
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def is_expired(self) -> bool:
|
|
166
|
+
"""Return ``True`` if the TTL has elapsed since creation."""
|
|
167
|
+
return time.time() > self.created_at + self.ttl_seconds
|
|
168
|
+
|
|
169
|
+
def activate(self) -> None:
|
|
170
|
+
"""Transition to :attr:`AGECStatus.ACTIVE` after validation."""
|
|
171
|
+
self._transition(AGECStatus.ACTIVE)
|
|
172
|
+
|
|
173
|
+
def suspend(self) -> None:
|
|
174
|
+
"""Transition to :attr:`AGECStatus.SUSPENDED` on policy failure."""
|
|
175
|
+
self._transition(AGECStatus.SUSPENDED)
|
|
176
|
+
|
|
177
|
+
def cancel(self) -> None:
|
|
178
|
+
"""Transition to :attr:`AGECStatus.CANCELLED` (e.g. on expiry)."""
|
|
179
|
+
self._transition(AGECStatus.CANCELLED)
|
|
180
|
+
|
|
181
|
+
def start_execution(self) -> None:
|
|
182
|
+
"""Transition to :attr:`AGECStatus.EXECUTING`.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
AGECTransitionError: If the context is not yet active.
|
|
186
|
+
"""
|
|
187
|
+
self._transition(AGECStatus.EXECUTING)
|
|
188
|
+
|
|
189
|
+
def complete(self) -> None:
|
|
190
|
+
"""Transition to :attr:`AGECStatus.COMPLETED`.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
AGECTransitionError: If the context is not executing.
|
|
194
|
+
"""
|
|
195
|
+
self._transition(AGECStatus.COMPLETED)
|
agec/guard.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Pre-execution governance decorator for AI agent actions.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from agec import guard
|
|
6
|
+
|
|
7
|
+
@guard(
|
|
8
|
+
intent="send_email",
|
|
9
|
+
purpose="customer_support",
|
|
10
|
+
allowed_tools=["gmail.send"],
|
|
11
|
+
legal_basis="consent",
|
|
12
|
+
)
|
|
13
|
+
def send_email() -> str:
|
|
14
|
+
return "Email sent."
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from functools import wraps
|
|
21
|
+
from typing import Any, TypeVar
|
|
22
|
+
|
|
23
|
+
from .core import AGEC, AGECTransitionError, DataPermissions, ExecutionPath, Intent
|
|
24
|
+
from .policies import Policy
|
|
25
|
+
from .validator import AGECValidator
|
|
26
|
+
|
|
27
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AGECBlockedError(PermissionError):
|
|
31
|
+
"""Raised when the AGEC validator denies execution.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
reason: Human-readable denial reason from the validator.
|
|
35
|
+
agec: The :class:`~agec.core.AGEC` context that was denied.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, reason: str, agec: AGEC) -> None:
|
|
39
|
+
super().__init__(reason)
|
|
40
|
+
self.reason = reason
|
|
41
|
+
self.agec = agec
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def guard(
|
|
45
|
+
*,
|
|
46
|
+
intent: str,
|
|
47
|
+
purpose: str,
|
|
48
|
+
allowed_tools: list[str],
|
|
49
|
+
legal_basis: str = "consent",
|
|
50
|
+
data_categories: list[str] | None = None,
|
|
51
|
+
minimum_intent_confidence: float = 0.7,
|
|
52
|
+
intent_confidence: float = 1.0,
|
|
53
|
+
) -> Callable[[F], F]:
|
|
54
|
+
"""Decorator that enforces AGEC governance before function execution.
|
|
55
|
+
|
|
56
|
+
The :class:`~agec.policies.Policy` and
|
|
57
|
+
:class:`~agec.validator.AGECValidator` are created **once** at
|
|
58
|
+
decoration time to avoid per-call overhead. A fresh
|
|
59
|
+
:class:`~agec.core.AGEC` context is created on every invocation to
|
|
60
|
+
ensure independent TTL and audit tracking per call.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
intent: Intent type string, e.g. ``"send_email"``.
|
|
64
|
+
purpose: Data processing purpose, e.g. ``"customer_support"``.
|
|
65
|
+
allowed_tools: List of tool identifiers permitted by this action.
|
|
66
|
+
legal_basis: GDPR legal basis (default ``"consent"``).
|
|
67
|
+
data_categories: Optional list of personal data categories.
|
|
68
|
+
minimum_intent_confidence: Minimum acceptable confidence score
|
|
69
|
+
(default ``0.7``).
|
|
70
|
+
intent_confidence: Confidence score to assign to this intent
|
|
71
|
+
(default ``1.0``).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A decorator that wraps the target function with AGEC governance.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
AGECBlockedError: If the validator denies execution.
|
|
78
|
+
|
|
79
|
+
Example::
|
|
80
|
+
|
|
81
|
+
@guard(intent="send_email", purpose="support",
|
|
82
|
+
allowed_tools=["gmail.send"])
|
|
83
|
+
def send_email() -> str:
|
|
84
|
+
return "Email sent."
|
|
85
|
+
"""
|
|
86
|
+
# Build policy once at decoration time — not per call.
|
|
87
|
+
_policy = Policy(
|
|
88
|
+
allowed_intents=[intent],
|
|
89
|
+
allowed_tools=allowed_tools,
|
|
90
|
+
allowed_purposes=[purpose],
|
|
91
|
+
allowed_legal_bases=[legal_basis],
|
|
92
|
+
minimum_intent_confidence=minimum_intent_confidence,
|
|
93
|
+
)
|
|
94
|
+
_validator = AGECValidator(_policy)
|
|
95
|
+
|
|
96
|
+
def decorator(func: F) -> F:
|
|
97
|
+
@wraps(func)
|
|
98
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
99
|
+
# Fresh AGEC context per call (independent TTL & audit ID).
|
|
100
|
+
agec = AGEC(
|
|
101
|
+
intent=Intent(type=intent, confidence=intent_confidence),
|
|
102
|
+
context={"function": func.__name__},
|
|
103
|
+
execution_path=ExecutionPath(
|
|
104
|
+
path_id=f"{func.__name__}_path",
|
|
105
|
+
steps=allowed_tools,
|
|
106
|
+
),
|
|
107
|
+
data_permissions=DataPermissions(
|
|
108
|
+
purpose=purpose,
|
|
109
|
+
legal_basis=legal_basis,
|
|
110
|
+
allowed_operations=["execute"],
|
|
111
|
+
data_categories=data_categories or [],
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
result = _validator.validate(agec)
|
|
115
|
+
if not result.allowed:
|
|
116
|
+
raise AGECBlockedError(result.reason, agec)
|
|
117
|
+
|
|
118
|
+
agec.start_execution()
|
|
119
|
+
try:
|
|
120
|
+
return func(*args, **kwargs)
|
|
121
|
+
finally:
|
|
122
|
+
try:
|
|
123
|
+
agec.complete()
|
|
124
|
+
except AGECTransitionError:
|
|
125
|
+
pass # Guard should not raise on cleanup.
|
|
126
|
+
|
|
127
|
+
return wrapper # type: ignore[return-value]
|
|
128
|
+
|
|
129
|
+
return decorator
|
agec/policies.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""AGEC policy definitions and built-in legal basis vocabulary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_LEGAL_BASES: list[str] = [
|
|
9
|
+
"consent",
|
|
10
|
+
"contract",
|
|
11
|
+
"legal_obligation",
|
|
12
|
+
"vital_interest",
|
|
13
|
+
"public_task",
|
|
14
|
+
"legitimate_interest",
|
|
15
|
+
]
|
|
16
|
+
"""GDPR Article 6 legal bases supported by AGEC out of the box."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Policy:
|
|
21
|
+
"""Declares what an agent is allowed to do.
|
|
22
|
+
|
|
23
|
+
A :class:`Policy` is the authoritative source of truth for a single
|
|
24
|
+
governance context. It specifies which intents, tools, purposes, and
|
|
25
|
+
legal bases are permitted, and optionally blocks certain data
|
|
26
|
+
categories.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
allowed_intents: Intent types the agent may declare.
|
|
30
|
+
allowed_tools: Tool identifiers the agent may invoke.
|
|
31
|
+
allowed_purposes: Data processing purposes that are permitted.
|
|
32
|
+
allowed_legal_bases: GDPR legal bases accepted for this policy.
|
|
33
|
+
blocked_data_categories: Data categories explicitly disallowed.
|
|
34
|
+
``None`` means no categories are blocked.
|
|
35
|
+
minimum_intent_confidence: Minimum confidence score required for
|
|
36
|
+
an intent to be accepted (default ``0.7``).
|
|
37
|
+
|
|
38
|
+
Example::
|
|
39
|
+
|
|
40
|
+
policy = Policy(
|
|
41
|
+
allowed_intents=["send_email"],
|
|
42
|
+
allowed_tools=["gmail.send"],
|
|
43
|
+
allowed_purposes=["customer_support"],
|
|
44
|
+
allowed_legal_bases=["consent"],
|
|
45
|
+
)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
allowed_intents: list[str]
|
|
49
|
+
allowed_tools: list[str]
|
|
50
|
+
allowed_purposes: list[str]
|
|
51
|
+
allowed_legal_bases: list[str]
|
|
52
|
+
blocked_data_categories: list[str] | None = None
|
|
53
|
+
minimum_intent_confidence: float = 0.7
|
|
54
|
+
|
|
55
|
+
def is_intent_allowed(self, intent: str) -> bool:
|
|
56
|
+
"""Return ``True`` if *intent* is in :attr:`allowed_intents`."""
|
|
57
|
+
return intent in self.allowed_intents
|
|
58
|
+
|
|
59
|
+
def is_tool_allowed(self, tool: str) -> bool:
|
|
60
|
+
"""Return ``True`` if *tool* is in :attr:`allowed_tools`."""
|
|
61
|
+
return tool in self.allowed_tools
|
|
62
|
+
|
|
63
|
+
def is_purpose_allowed(self, purpose: str) -> bool:
|
|
64
|
+
"""Return ``True`` if *purpose* is in :attr:`allowed_purposes`."""
|
|
65
|
+
return purpose in self.allowed_purposes
|
|
66
|
+
|
|
67
|
+
def is_legal_basis_allowed(self, legal_basis: str) -> bool:
|
|
68
|
+
"""Return ``True`` if *legal_basis* is in :attr:`allowed_legal_bases`."""
|
|
69
|
+
return legal_basis in self.allowed_legal_bases
|
|
70
|
+
|
|
71
|
+
def has_blocked_data_category(self, categories: list[str]) -> bool:
|
|
72
|
+
"""Return ``True`` if any category in *categories* is blocked.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
categories: Data categories to check against the block list.
|
|
76
|
+
"""
|
|
77
|
+
if not self.blocked_data_categories:
|
|
78
|
+
return False
|
|
79
|
+
return any(category in self.blocked_data_categories for category in categories)
|
agec/validator.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""AGEC validation engine.
|
|
2
|
+
|
|
3
|
+
Runs a deterministic, ordered series of policy checks against an
|
|
4
|
+
:class:`~agec.core.AGEC` context and records every decision in an
|
|
5
|
+
:class:`~agec.audit.AuditLog`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .audit import AuditLog
|
|
13
|
+
from .core import AGEC
|
|
14
|
+
from .policies import Policy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ValidationResult:
|
|
19
|
+
"""Outcome of a single validation run.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
allowed: ``True`` if all policy checks passed.
|
|
23
|
+
reason: Human-readable explanation of the outcome.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
allowed: bool
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AGECValidator:
|
|
31
|
+
"""Validates an :class:`~agec.core.AGEC` context against a :class:`~agec.policies.Policy`.
|
|
32
|
+
|
|
33
|
+
Checks are evaluated in this order:
|
|
34
|
+
|
|
35
|
+
1. Expiry (TTL)
|
|
36
|
+
2. Intent allowlist
|
|
37
|
+
3. Intent confidence threshold
|
|
38
|
+
4. Execution path non-empty
|
|
39
|
+
5. Tool allowlist (per step)
|
|
40
|
+
6. Purpose allowlist
|
|
41
|
+
7. Legal basis allowlist
|
|
42
|
+
8. Blocked data categories
|
|
43
|
+
|
|
44
|
+
Every outcome — allow or deny — is recorded in the
|
|
45
|
+
:class:`~agec.audit.AuditLog`.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
policy: The :class:`~agec.policies.Policy` to validate against.
|
|
49
|
+
audit_log: Optional existing :class:`~agec.audit.AuditLog` to
|
|
50
|
+
append events to. A new log is created if omitted.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, policy: Policy, audit_log: AuditLog | None = None) -> None:
|
|
54
|
+
self.policy = policy
|
|
55
|
+
self.audit_log = audit_log or AuditLog()
|
|
56
|
+
|
|
57
|
+
def validate(self, agec: AGEC) -> ValidationResult:
|
|
58
|
+
"""Run all policy checks against *agec*.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
agec: The governance context to validate.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A :class:`ValidationResult` indicating allow or deny.
|
|
65
|
+
"""
|
|
66
|
+
if agec.is_expired():
|
|
67
|
+
agec.cancel()
|
|
68
|
+
return self._deny(agec, "AGEC expired.")
|
|
69
|
+
|
|
70
|
+
if not self.policy.is_intent_allowed(agec.intent.type):
|
|
71
|
+
agec.suspend()
|
|
72
|
+
return self._deny(agec, f"Intent not allowed: {agec.intent.type}")
|
|
73
|
+
|
|
74
|
+
if agec.intent.confidence < self.policy.minimum_intent_confidence:
|
|
75
|
+
agec.suspend()
|
|
76
|
+
return self._deny(agec, "Intent confidence below threshold.")
|
|
77
|
+
|
|
78
|
+
if not agec.execution_path.steps:
|
|
79
|
+
agec.suspend()
|
|
80
|
+
return self._deny(agec, "Execution path is empty.")
|
|
81
|
+
|
|
82
|
+
for tool in agec.execution_path.steps:
|
|
83
|
+
if not self.policy.is_tool_allowed(tool):
|
|
84
|
+
agec.suspend()
|
|
85
|
+
return self._deny(agec, f"Tool not allowed: {tool}")
|
|
86
|
+
|
|
87
|
+
if not self.policy.is_purpose_allowed(agec.data_permissions.purpose):
|
|
88
|
+
agec.suspend()
|
|
89
|
+
return self._deny(
|
|
90
|
+
agec,
|
|
91
|
+
f"Purpose not allowed: {agec.data_permissions.purpose}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not self.policy.is_legal_basis_allowed(agec.data_permissions.legal_basis):
|
|
95
|
+
agec.suspend()
|
|
96
|
+
return self._deny(
|
|
97
|
+
agec,
|
|
98
|
+
f"Legal basis not allowed: {agec.data_permissions.legal_basis}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if self.policy.has_blocked_data_category(agec.data_permissions.data_categories):
|
|
102
|
+
agec.suspend()
|
|
103
|
+
return self._deny(agec, "Blocked data category detected.")
|
|
104
|
+
|
|
105
|
+
agec.activate()
|
|
106
|
+
self.audit_log.record(
|
|
107
|
+
agec.agec_id,
|
|
108
|
+
"validation.allowed",
|
|
109
|
+
"AGEC validation passed.",
|
|
110
|
+
{
|
|
111
|
+
"intent": agec.intent.type,
|
|
112
|
+
"path_hash": agec.execution_path.deterministic_hash(),
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
return ValidationResult(True, "AGEC validation passed.")
|
|
116
|
+
|
|
117
|
+
def _deny(self, agec: AGEC, reason: str) -> ValidationResult:
|
|
118
|
+
"""Record a denial event and return a denied :class:`ValidationResult`."""
|
|
119
|
+
self.audit_log.record(
|
|
120
|
+
agec.agec_id,
|
|
121
|
+
"validation.denied",
|
|
122
|
+
reason,
|
|
123
|
+
{
|
|
124
|
+
"intent": agec.intent.type,
|
|
125
|
+
"status": agec.status.value,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
return ValidationResult(False, reason)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate(agec: AGEC, policy: Policy) -> ValidationResult:
|
|
132
|
+
"""Convenience function: validate *agec* against *policy*.
|
|
133
|
+
|
|
134
|
+
Creates a throw-away :class:`AGECValidator` with a fresh
|
|
135
|
+
:class:`~agec.audit.AuditLog`. Prefer constructing a validator
|
|
136
|
+
directly when you need to inspect audit events afterward.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
agec: The governance context to validate.
|
|
140
|
+
policy: The policy to validate against.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A :class:`ValidationResult`.
|
|
144
|
+
"""
|
|
145
|
+
return AGECValidator(policy).validate(agec)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agec
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Authorized Governance Execution Context for AI agents
|
|
5
|
+
Author-email: Onur Esmercan <onuresmercan@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/onur-esmercan/agec
|
|
8
|
+
Project-URL: Repository, https://github.com/onur-esmercan/agec
|
|
9
|
+
Keywords: ai-agents,authorization,governance,audit,security
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Provides-Extra: openai
|
|
21
|
+
Requires-Dist: openai-agents>=0.1; extra == "openai"
|
|
22
|
+
Provides-Extra: langchain
|
|
23
|
+
Requires-Dist: langchain-core>=0.2; extra == "langchain"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# AGEC
|
|
29
|
+
|
|
30
|
+
Pre-execution governance layer for AI agents.
|
|
31
|
+
|
|
32
|
+
Every AI agent action should be authorized before execution. AGEC validates
|
|
33
|
+
intent, semantic context, execution path, and data processing permissions
|
|
34
|
+
before any tool call is executed. It provides deterministic authorization,
|
|
35
|
+
replayable decisions, and auditability for autonomous AI agents.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install agec
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from agec import guard, AGECBlockedError
|
|
49
|
+
|
|
50
|
+
@guard(
|
|
51
|
+
intent="send_email",
|
|
52
|
+
purpose="customer_support",
|
|
53
|
+
allowed_tools=["gmail.send"],
|
|
54
|
+
legal_basis="consent",
|
|
55
|
+
)
|
|
56
|
+
def send_email() -> str:
|
|
57
|
+
return "Email sent."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@guard(
|
|
61
|
+
intent="transfer_money",
|
|
62
|
+
purpose="unknown",
|
|
63
|
+
allowed_tools=["bank.transfer"],
|
|
64
|
+
intent_confidence=0.48, # below the 0.7 threshold
|
|
65
|
+
)
|
|
66
|
+
def unsafe_transfer() -> str:
|
|
67
|
+
return "Transferred $1M."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
print(send_email()) # → Email sent.
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
unsafe_transfer()
|
|
74
|
+
except AGECBlockedError as exc:
|
|
75
|
+
print("AGEC BLOCKED EXECUTION")
|
|
76
|
+
print(f"Reason: {exc.reason}")
|
|
77
|
+
print(f"Audit ID: {exc.agec.agec_id}")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
If validation fails, execution is blocked **before** the wrapped function runs.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Why AGEC?
|
|
85
|
+
|
|
86
|
+
Traditional authorization systems answer:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
Can this identity access this resource?
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
AGEC answers a different question:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
Should this exact action execute right now?
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
AGEC introduces a mandatory governance layer between agent planning and tool
|
|
99
|
+
execution — combining intent validation, policy enforcement, and tamper-evident
|
|
100
|
+
audit logging in a single decorator.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## What AGEC Validates
|
|
105
|
+
|
|
106
|
+
| Check | What it enforces |
|
|
107
|
+
|---|---|
|
|
108
|
+
| **Intent** | Declared intent must be in the policy allowlist |
|
|
109
|
+
| **Confidence** | Intent confidence must meet the minimum threshold |
|
|
110
|
+
| **Execution path** | Every tool step must be explicitly permitted |
|
|
111
|
+
| **Purpose** | Data processing purpose must be allowed |
|
|
112
|
+
| **Legal basis** | GDPR-aligned legal basis must be declared and allowed |
|
|
113
|
+
| **Data categories** | Blocked data categories are rejected before execution |
|
|
114
|
+
| **Expiry (TTL)** | Stale contexts are cancelled automatically |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Architecture
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
User
|
|
122
|
+
│
|
|
123
|
+
AI Agent (planning)
|
|
124
|
+
│
|
|
125
|
+
AGEC ◄─── Policy + Validator
|
|
126
|
+
│ │
|
|
127
|
+
│ AuditLog ──► audit.jsonl (optional)
|
|
128
|
+
│
|
|
129
|
+
Tool Execution
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Lower-Level API
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from agec import AGEC, Intent, ExecutionPath, DataPermissions, Policy, AGECValidator
|
|
138
|
+
|
|
139
|
+
policy = Policy(
|
|
140
|
+
allowed_intents=["send_email"],
|
|
141
|
+
allowed_tools=["gmail.send"],
|
|
142
|
+
allowed_purposes=["customer_support"],
|
|
143
|
+
allowed_legal_bases=["consent", "contract"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
agec = AGEC(
|
|
147
|
+
intent=Intent(type="send_email", confidence=0.95),
|
|
148
|
+
context={"user_id": "123"},
|
|
149
|
+
execution_path=ExecutionPath(path_id="email_path", steps=["gmail.send"]),
|
|
150
|
+
data_permissions=DataPermissions(
|
|
151
|
+
purpose="customer_support",
|
|
152
|
+
legal_basis="consent",
|
|
153
|
+
allowed_operations=["send"],
|
|
154
|
+
data_categories=["email"],
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
validator = AGECValidator(policy)
|
|
159
|
+
result = validator.validate(agec)
|
|
160
|
+
|
|
161
|
+
print(result.allowed) # True
|
|
162
|
+
print(result.reason) # AGEC validation passed.
|
|
163
|
+
print(agec.status) # AGECStatus.ACTIVE
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Persisting the Audit Log
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from agec import AuditLog
|
|
170
|
+
|
|
171
|
+
log = AuditLog()
|
|
172
|
+
# ... pass log to AGECValidator(policy, audit_log=log) ...
|
|
173
|
+
|
|
174
|
+
# Save all recorded events to disk
|
|
175
|
+
log.save_json("audit.jsonl")
|
|
176
|
+
|
|
177
|
+
# Reload later for replay or compliance review
|
|
178
|
+
restored = AuditLog.load_json("audit.jsonl")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Roadmap
|
|
184
|
+
|
|
185
|
+
- [ ] OpenAI Agents SDK adapter
|
|
186
|
+
- [ ] LangGraph adapter
|
|
187
|
+
- [ ] CrewAI adapter
|
|
188
|
+
- [ ] AutoGen adapter
|
|
189
|
+
- [ ] CLI demo runner
|
|
190
|
+
- [ ] Policy manifest (YAML/JSON) support
|
|
191
|
+
- [x] Replayable audit log (JSON persistence)
|
|
192
|
+
- [x] Deterministic execution path hashing
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Contributing
|
|
197
|
+
|
|
198
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
199
|
+
|
|
200
|
+
## Changelog
|
|
201
|
+
|
|
202
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
Apache-2.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
agec/__init__.py,sha256=C5DN-LzjHnjsCkzjUk9S3AOYgZlsjtRZThAoE4ZeghI,1100
|
|
2
|
+
agec/audit.py,sha256=QYyiN0oYEvSk8qNt3_2H3WHB6VGDpBRhy0i1wG_WYUM,3590
|
|
3
|
+
agec/core.py,sha256=cS5OQnDc7z92NdHSJVSdHv0BK_9d5y8heJAZONclOZs,6367
|
|
4
|
+
agec/guard.py,sha256=LITzbl_nSnmv3_fRxkcpn18QfRBLZY67_Kcr4dh-Uk4,4188
|
|
5
|
+
agec/policies.py,sha256=lAzINAqLM0kNg4L9Ak8M_-l6AiMCTQrHoCvN4_hdbeA,2787
|
|
6
|
+
agec/validator.py,sha256=r3EwzPE1TdQOS2dqz1V1rzSQamCafaucIj0DN6H8VnE,4573
|
|
7
|
+
agec-0.1.0.dist-info/METADATA,sha256=43_dPbmBz1FgWOl6Y1ofE8XfxyzN2_ADxoJyroJwQD0,5221
|
|
8
|
+
agec-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
agec-0.1.0.dist-info/top_level.txt,sha256=2_EJnPlPTZR-mRreLfAW-uqn5Ak4HtxG9vi-il5-kdc,5
|
|
10
|
+
agec-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agec
|