aegize 0.2.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.
- aegize/__init__.py +59 -0
- aegize/_time.py +10 -0
- aegize/action.py +52 -0
- aegize/audit.py +83 -0
- aegize/context.py +74 -0
- aegize/decorators.py +139 -0
- aegize/exceptions.py +51 -0
- aegize/guarded_tool.py +160 -0
- aegize/identity.py +47 -0
- aegize/policy.py +209 -0
- aegize/py.typed +0 -0
- aegize-0.2.0.dist-info/METADATA +414 -0
- aegize-0.2.0.dist-info/RECORD +15 -0
- aegize-0.2.0.dist-info/WHEEL +4 -0
- aegize-0.2.0.dist-info/licenses/LICENSE +21 -0
aegize/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Aegize: infrastructure for autonomous AI agents.
|
|
2
|
+
|
|
3
|
+
Aegize provides the runtime governance layer between an agent and its tools:
|
|
4
|
+
identity, policy, permissions, approval workflows, and audit. Every tool call
|
|
5
|
+
carries an identity, is evaluated against policy, and is written to an
|
|
6
|
+
append-only audit log before it is allowed to run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .action import RISK_LEVELS, ToolAction
|
|
12
|
+
from .audit import AuditLog
|
|
13
|
+
from .context import (
|
|
14
|
+
GuardContext,
|
|
15
|
+
clear_default_context,
|
|
16
|
+
get_default_context,
|
|
17
|
+
set_default_context,
|
|
18
|
+
)
|
|
19
|
+
from .decorators import GuardedFunction, GuardSpec, guard, guarded_tool
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
AegizeError,
|
|
22
|
+
ApprovalRequired,
|
|
23
|
+
PolicyDenied,
|
|
24
|
+
PolicyLoadError,
|
|
25
|
+
)
|
|
26
|
+
from .guarded_tool import GuardedTool
|
|
27
|
+
from .identity import AgentIdentity
|
|
28
|
+
from .policy import Decision, PermissionPolicy, PolicyResult
|
|
29
|
+
|
|
30
|
+
__version__ = "0.2.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"__version__",
|
|
34
|
+
# primitives
|
|
35
|
+
"AgentIdentity",
|
|
36
|
+
"ToolAction",
|
|
37
|
+
"RISK_LEVELS",
|
|
38
|
+
# policy
|
|
39
|
+
"PermissionPolicy",
|
|
40
|
+
"PolicyResult",
|
|
41
|
+
"Decision",
|
|
42
|
+
# enforcement + audit
|
|
43
|
+
"GuardedTool",
|
|
44
|
+
"AuditLog",
|
|
45
|
+
# ergonomics (v0.2)
|
|
46
|
+
"guarded_tool",
|
|
47
|
+
"guard",
|
|
48
|
+
"GuardedFunction",
|
|
49
|
+
"GuardSpec",
|
|
50
|
+
"GuardContext",
|
|
51
|
+
"set_default_context",
|
|
52
|
+
"get_default_context",
|
|
53
|
+
"clear_default_context",
|
|
54
|
+
# exceptions
|
|
55
|
+
"AegizeError",
|
|
56
|
+
"PolicyDenied",
|
|
57
|
+
"ApprovalRequired",
|
|
58
|
+
"PolicyLoadError",
|
|
59
|
+
]
|
aegize/_time.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Internal time helpers, kept in one place so timestamps stay consistent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def utcnow() -> datetime:
|
|
9
|
+
"""Return a timezone-aware UTC timestamp."""
|
|
10
|
+
return datetime.now(timezone.utc)
|
aegize/action.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tool action primitive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from ._time import utcnow
|
|
11
|
+
|
|
12
|
+
RISK_LEVELS = ("low", "medium", "high", "critical")
|
|
13
|
+
|
|
14
|
+
# Numeric ordering so that risk levels can be compared (e.g. risk_level_max).
|
|
15
|
+
RISK_ORDER: dict[str, int] = {level: i for i, level in enumerate(RISK_LEVELS)}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ToolAction:
|
|
20
|
+
"""A single attempted tool call, evaluated against policy and audited.
|
|
21
|
+
|
|
22
|
+
``ToolAction`` is normally constructed for you by :class:`GuardedTool`, but
|
|
23
|
+
it is a plain dataclass so it can be built and inspected directly in tests
|
|
24
|
+
or custom integrations.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
agent_id: str
|
|
28
|
+
tool_name: str
|
|
29
|
+
operation: str
|
|
30
|
+
input_summary: str = ""
|
|
31
|
+
risk_level: str = "low"
|
|
32
|
+
action_id: str = field(default_factory=lambda: str(uuid4()))
|
|
33
|
+
timestamp: datetime = field(default_factory=utcnow)
|
|
34
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if self.risk_level not in RISK_LEVELS:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"risk_level must be one of {RISK_LEVELS}, got {self.risk_level!r}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"action_id": self.action_id,
|
|
45
|
+
"agent_id": self.agent_id,
|
|
46
|
+
"tool_name": self.tool_name,
|
|
47
|
+
"operation": self.operation,
|
|
48
|
+
"input_summary": self.input_summary,
|
|
49
|
+
"risk_level": self.risk_level,
|
|
50
|
+
"timestamp": self.timestamp.isoformat(),
|
|
51
|
+
"metadata": dict(self.metadata),
|
|
52
|
+
}
|
aegize/audit.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Append-only JSONL audit log.
|
|
2
|
+
|
|
3
|
+
Every attempted action produces at least one audit record. Allowed actions
|
|
4
|
+
produce two: one when the action is authorized (before execution) and one for
|
|
5
|
+
the execution result (success or failure). The log is append-only and one JSON
|
|
6
|
+
object per line, so it is trivial to tail, grep, or ship to a SIEM.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ._time import utcnow
|
|
17
|
+
from .action import ToolAction
|
|
18
|
+
|
|
19
|
+
# Canonical event names written to the audit log.
|
|
20
|
+
EVENT_ALLOWED = "allowed"
|
|
21
|
+
EVENT_DENIED = "denied"
|
|
22
|
+
EVENT_APPROVAL_REQUIRED = "approval_required"
|
|
23
|
+
EVENT_EXECUTION_SUCCEEDED = "execution_succeeded"
|
|
24
|
+
EVENT_EXECUTION_FAILED = "execution_failed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuditLog:
|
|
28
|
+
"""Writes audit records to a JSONL file (one JSON object per line)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: str | Path) -> None:
|
|
31
|
+
self.path = Path(path)
|
|
32
|
+
parent = self.path.parent
|
|
33
|
+
if parent and not parent.exists():
|
|
34
|
+
os.makedirs(parent, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
def record(
|
|
37
|
+
self,
|
|
38
|
+
action: ToolAction,
|
|
39
|
+
event: str,
|
|
40
|
+
*,
|
|
41
|
+
reason: str | None = None,
|
|
42
|
+
error: str | None = None,
|
|
43
|
+
result_summary: str | None = None,
|
|
44
|
+
extra: dict[str, Any] | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Append one audit entry and return the written record."""
|
|
47
|
+
entry: dict[str, Any] = {
|
|
48
|
+
"timestamp": utcnow().isoformat(),
|
|
49
|
+
"event": event,
|
|
50
|
+
"action_id": action.action_id,
|
|
51
|
+
"agent_id": action.agent_id,
|
|
52
|
+
"tool_name": action.tool_name,
|
|
53
|
+
"operation": action.operation,
|
|
54
|
+
"risk_level": action.risk_level,
|
|
55
|
+
"input_summary": action.input_summary,
|
|
56
|
+
}
|
|
57
|
+
if reason is not None:
|
|
58
|
+
entry["reason"] = reason
|
|
59
|
+
if error is not None:
|
|
60
|
+
entry["error"] = error
|
|
61
|
+
if result_summary is not None:
|
|
62
|
+
entry["result_summary"] = result_summary
|
|
63
|
+
if extra:
|
|
64
|
+
entry.update(extra)
|
|
65
|
+
self._write(entry)
|
|
66
|
+
return entry
|
|
67
|
+
|
|
68
|
+
def _write(self, entry: dict[str, Any]) -> None:
|
|
69
|
+
line = json.dumps(entry, default=str, ensure_ascii=False)
|
|
70
|
+
with open(self.path, "a", encoding="utf-8") as handle:
|
|
71
|
+
handle.write(line + "\n")
|
|
72
|
+
|
|
73
|
+
def read_all(self) -> list[dict[str, Any]]:
|
|
74
|
+
"""Read every record back. Convenience for tests and small tools."""
|
|
75
|
+
if not self.path.exists():
|
|
76
|
+
return []
|
|
77
|
+
records: list[dict[str, Any]] = []
|
|
78
|
+
with open(self.path, encoding="utf-8") as handle:
|
|
79
|
+
for line in handle:
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if line:
|
|
82
|
+
records.append(json.loads(line))
|
|
83
|
+
return records
|
aegize/context.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""GuardContext: bundles the agent, policy, and audit log together.
|
|
2
|
+
|
|
3
|
+
A ``GuardContext`` is the "who + rules + ledger" needed to enforce any tool
|
|
4
|
+
call. It lets you stop threading ``agent`` / ``policy`` / ``audit_log`` through
|
|
5
|
+
every :class:`GuardedTool` or decorated function.
|
|
6
|
+
|
|
7
|
+
It can also be installed as the *default* context — either explicitly via
|
|
8
|
+
:meth:`GuardContext.activate` or temporarily with a ``with`` block — so that
|
|
9
|
+
``@guarded_tool``-decorated functions can be called directly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from .audit import AuditLog
|
|
18
|
+
from .identity import AgentIdentity
|
|
19
|
+
from .policy import PermissionPolicy
|
|
20
|
+
|
|
21
|
+
# Explicitly-installed default (via activate()), plus a stack for `with` blocks.
|
|
22
|
+
_default_context: GuardContext | None = None
|
|
23
|
+
_context_stack: list[GuardContext] = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class GuardContext:
|
|
28
|
+
"""The agent, policy, and audit log required to guard a tool call."""
|
|
29
|
+
|
|
30
|
+
agent: AgentIdentity
|
|
31
|
+
policy: PermissionPolicy
|
|
32
|
+
audit_log: AuditLog
|
|
33
|
+
|
|
34
|
+
def guard(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
35
|
+
"""Bind a ``@guarded_tool``-decorated function to this context.
|
|
36
|
+
|
|
37
|
+
Returns a plain callable (signature-preserving) suitable for handing to
|
|
38
|
+
a tool registry, e.g. ``server.add_tool(ctx.guard(send_email))``.
|
|
39
|
+
"""
|
|
40
|
+
from .decorators import guard
|
|
41
|
+
|
|
42
|
+
return guard(fn, context=self)
|
|
43
|
+
|
|
44
|
+
def activate(self) -> GuardContext:
|
|
45
|
+
"""Install this context as the process-wide default. Returns self."""
|
|
46
|
+
set_default_context(self)
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> GuardContext:
|
|
50
|
+
_context_stack.append(self)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *exc: Any) -> bool:
|
|
54
|
+
if _context_stack and _context_stack[-1] is self:
|
|
55
|
+
_context_stack.pop()
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_default_context(context: GuardContext | None) -> None:
|
|
60
|
+
"""Install (or clear, with ``None``) the process-wide default context."""
|
|
61
|
+
global _default_context
|
|
62
|
+
_default_context = context
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_default_context() -> GuardContext | None:
|
|
66
|
+
"""Return the active context: the innermost ``with`` block, else the default."""
|
|
67
|
+
if _context_stack:
|
|
68
|
+
return _context_stack[-1]
|
|
69
|
+
return _default_context
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clear_default_context() -> None:
|
|
73
|
+
"""Remove the process-wide default context."""
|
|
74
|
+
set_default_context(None)
|
aegize/decorators.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The ``@guarded_tool`` decorator and the ``guard()`` adapter.
|
|
2
|
+
|
|
3
|
+
These are pure ergonomics on top of :class:`GuardedTool`. Decorate a function
|
|
4
|
+
once with its policy coordinates, then bind it to a :class:`GuardContext` when
|
|
5
|
+
you have one::
|
|
6
|
+
|
|
7
|
+
@guarded_tool(tool_name="email", operation="send", risk_level="high")
|
|
8
|
+
def send_email(to: str, body: str) -> str:
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
# later, with a context in hand:
|
|
12
|
+
server.add_tool(guard(send_email, context=ctx))
|
|
13
|
+
|
|
14
|
+
The object returned by ``guard()`` is a signature-preserving callable, so tool
|
|
15
|
+
registries (including MCP servers) that introspect ``__name__`` and the function
|
|
16
|
+
signature keep working.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
from .context import GuardContext, get_default_context
|
|
26
|
+
from .exceptions import AegizeError
|
|
27
|
+
from .guarded_tool import GuardedTool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class GuardSpec:
|
|
32
|
+
"""The policy coordinates captured by ``@guarded_tool`` at decoration time."""
|
|
33
|
+
|
|
34
|
+
tool_name: str
|
|
35
|
+
operation: str
|
|
36
|
+
risk_level: str = "low"
|
|
37
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GuardedFunction:
|
|
41
|
+
"""A function tagged with a :class:`GuardSpec` by ``@guarded_tool``.
|
|
42
|
+
|
|
43
|
+
Calling it directly enforces policy using the active default context (set
|
|
44
|
+
via ``with context:`` or :meth:`GuardContext.activate`). To bind it to a
|
|
45
|
+
specific context explicitly, use :func:`guard` or :meth:`bind`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
func: Callable[..., Any],
|
|
51
|
+
spec: GuardSpec,
|
|
52
|
+
context: GuardContext | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
functools.update_wrapper(self, func)
|
|
55
|
+
self.__wrapped__ = func
|
|
56
|
+
self.spec = spec
|
|
57
|
+
self.context = context
|
|
58
|
+
|
|
59
|
+
def bind(self, context: GuardContext) -> GuardedTool:
|
|
60
|
+
"""Build a :class:`GuardedTool` for this function bound to ``context``."""
|
|
61
|
+
return GuardedTool(
|
|
62
|
+
tool_name=self.spec.tool_name,
|
|
63
|
+
operation=self.spec.operation,
|
|
64
|
+
func=self.__wrapped__,
|
|
65
|
+
agent=context.agent,
|
|
66
|
+
policy=context.policy,
|
|
67
|
+
audit_log=context.audit_log,
|
|
68
|
+
risk_level=self.spec.risk_level,
|
|
69
|
+
metadata=self.spec.metadata,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _resolve_context(self) -> GuardContext:
|
|
73
|
+
context = self.context or get_default_context()
|
|
74
|
+
if context is None:
|
|
75
|
+
raise AegizeError(
|
|
76
|
+
"no GuardContext is active for this guarded tool; bind one with "
|
|
77
|
+
"guard(fn, context=...), enter a `with context:` block, or install "
|
|
78
|
+
"a default via context.activate()"
|
|
79
|
+
)
|
|
80
|
+
return context
|
|
81
|
+
|
|
82
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
83
|
+
return self.bind(self._resolve_context())(*args, **kwargs)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def guarded_tool(
|
|
87
|
+
*,
|
|
88
|
+
tool_name: str,
|
|
89
|
+
operation: str,
|
|
90
|
+
risk_level: str = "low",
|
|
91
|
+
metadata: dict[str, Any] | None = None,
|
|
92
|
+
) -> Callable[[Callable[..., Any]], GuardedFunction]:
|
|
93
|
+
"""Decorator that tags a function with its Aegize policy coordinates."""
|
|
94
|
+
|
|
95
|
+
def decorator(func: Callable[..., Any]) -> GuardedFunction:
|
|
96
|
+
spec = GuardSpec(
|
|
97
|
+
tool_name=tool_name,
|
|
98
|
+
operation=operation,
|
|
99
|
+
risk_level=risk_level,
|
|
100
|
+
metadata=dict(metadata or {}),
|
|
101
|
+
)
|
|
102
|
+
return GuardedFunction(func, spec)
|
|
103
|
+
|
|
104
|
+
return decorator
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def guard(
|
|
108
|
+
fn: Callable[..., Any],
|
|
109
|
+
*,
|
|
110
|
+
context: GuardContext | None = None,
|
|
111
|
+
) -> Callable[..., Any]:
|
|
112
|
+
"""Bind a ``@guarded_tool`` function to a context, returning a callable.
|
|
113
|
+
|
|
114
|
+
The returned callable preserves the original function's name, docstring, and
|
|
115
|
+
signature, so it is a drop-in replacement that tool registries can introspect.
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(fn, GuardedFunction):
|
|
118
|
+
raise TypeError(
|
|
119
|
+
"guard() expects a function decorated with @guarded_tool; "
|
|
120
|
+
f"got {type(fn).__name__}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
resolved = context or fn.context or get_default_context()
|
|
124
|
+
if resolved is None:
|
|
125
|
+
raise AegizeError(
|
|
126
|
+
"guard() requires a GuardContext; pass context=... or install a "
|
|
127
|
+
"default via context.activate()"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
guarded = fn.bind(resolved)
|
|
131
|
+
|
|
132
|
+
@functools.wraps(fn.__wrapped__)
|
|
133
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
134
|
+
return guarded(*args, **kwargs)
|
|
135
|
+
|
|
136
|
+
# Expose the underlying objects for introspection / advanced use.
|
|
137
|
+
wrapper.guarded_tool = guarded # type: ignore[attr-defined]
|
|
138
|
+
wrapper.guard_spec = fn.spec # type: ignore[attr-defined]
|
|
139
|
+
return wrapper
|
aegize/exceptions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Exception hierarchy for Aegize.
|
|
2
|
+
|
|
3
|
+
All Aegize errors derive from :class:`AegizeError`, so callers can catch
|
|
4
|
+
the whole family with a single ``except`` clause while still being able to
|
|
5
|
+
distinguish a hard policy denial from an approval gate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
13
|
+
from .action import ToolAction
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AegizeError(Exception):
|
|
17
|
+
"""Base class for every error raised by Aegize."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PolicyLoadError(AegizeError):
|
|
21
|
+
"""Raised when a policy file cannot be read, parsed, or validated."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _DecisionError(AegizeError):
|
|
25
|
+
"""Shared base for errors that carry the offending action and a reason."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
reason: str | None = None,
|
|
32
|
+
action: ToolAction | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.reason = reason
|
|
36
|
+
self.action = action
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PolicyDenied(_DecisionError):
|
|
40
|
+
"""Raised when policy explicitly (or by default-deny) blocks an action.
|
|
41
|
+
|
|
42
|
+
The underlying tool function is never executed.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ApprovalRequired(_DecisionError):
|
|
47
|
+
"""Raised when an action needs human approval before it may run.
|
|
48
|
+
|
|
49
|
+
The underlying tool function is never executed. A human (or an out-of-band
|
|
50
|
+
approval workflow) must authorize the action and re-issue it.
|
|
51
|
+
"""
|
aegize/guarded_tool.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""GuardedTool: the enforcement point that wraps any callable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .action import ToolAction
|
|
8
|
+
from .audit import (
|
|
9
|
+
EVENT_ALLOWED,
|
|
10
|
+
EVENT_APPROVAL_REQUIRED,
|
|
11
|
+
EVENT_DENIED,
|
|
12
|
+
EVENT_EXECUTION_FAILED,
|
|
13
|
+
EVENT_EXECUTION_SUCCEEDED,
|
|
14
|
+
AuditLog,
|
|
15
|
+
)
|
|
16
|
+
from .exceptions import ApprovalRequired, PolicyDenied
|
|
17
|
+
from .identity import AgentIdentity
|
|
18
|
+
from .policy import Decision, PermissionPolicy
|
|
19
|
+
|
|
20
|
+
_SUMMARY_LIMIT = 200
|
|
21
|
+
|
|
22
|
+
# Reserved keyword for attaching per-call metadata. It is stripped from the
|
|
23
|
+
# arguments before the wrapped function is invoked, so the function never sees
|
|
24
|
+
# it. Used to drive path allowlists and to enrich audit records at call time.
|
|
25
|
+
GUARD_METADATA_KWARG = "guard_metadata"
|
|
26
|
+
|
|
27
|
+
# Internal bookkeeping keys that should not be echoed back into the audit log.
|
|
28
|
+
_INTERNAL_METADATA_KEYS = frozenset({"candidate_paths"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _truncate(text: str, limit: int = _SUMMARY_LIMIT) -> str:
|
|
32
|
+
if len(text) <= limit:
|
|
33
|
+
return text
|
|
34
|
+
return text[: limit - 3] + "..."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _summarize_inputs(args: tuple, kwargs: dict[str, Any]) -> str:
|
|
38
|
+
parts: list[str] = [repr(a) for a in args]
|
|
39
|
+
parts += [f"{k}={v!r}" for k, v in kwargs.items()]
|
|
40
|
+
return _truncate(", ".join(parts))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _string_inputs(args: tuple, kwargs: dict[str, Any]) -> list[str]:
|
|
44
|
+
"""Strings from the call that may represent paths (for path allowlists)."""
|
|
45
|
+
candidates = [a for a in args if isinstance(a, str)]
|
|
46
|
+
candidates += [v for v in kwargs.values() if isinstance(v, str)]
|
|
47
|
+
return candidates
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GuardedTool:
|
|
51
|
+
"""Wrap a callable so every invocation is permissioned, gated, and audited.
|
|
52
|
+
|
|
53
|
+
The wrapper is itself callable, so a ``GuardedTool`` is a drop-in
|
|
54
|
+
replacement for the function it guards::
|
|
55
|
+
|
|
56
|
+
safe_search = GuardedTool(
|
|
57
|
+
tool_name="web_search",
|
|
58
|
+
operation="search",
|
|
59
|
+
func=web_search,
|
|
60
|
+
agent=agent,
|
|
61
|
+
policy=policy,
|
|
62
|
+
audit_log=audit,
|
|
63
|
+
risk_level="low",
|
|
64
|
+
)
|
|
65
|
+
result = safe_search("AI safety companies")
|
|
66
|
+
|
|
67
|
+
Order of operations on every call:
|
|
68
|
+
|
|
69
|
+
1. Build a :class:`ToolAction` describing the attempt.
|
|
70
|
+
2. Evaluate it against the :class:`PermissionPolicy`.
|
|
71
|
+
3. Audit the decision **before** any execution.
|
|
72
|
+
4. Deny -> raise :class:`PolicyDenied` (function never runs).
|
|
73
|
+
Approval -> raise :class:`ApprovalRequired` (function never runs).
|
|
74
|
+
Allow -> run the function, then audit success or failure.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
tool_name: str,
|
|
81
|
+
operation: str,
|
|
82
|
+
func: Callable[..., Any],
|
|
83
|
+
agent: AgentIdentity,
|
|
84
|
+
policy: PermissionPolicy,
|
|
85
|
+
audit_log: AuditLog,
|
|
86
|
+
risk_level: str = "low",
|
|
87
|
+
metadata: dict[str, Any] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.tool_name = tool_name
|
|
90
|
+
self.operation = operation
|
|
91
|
+
self.func = func
|
|
92
|
+
self.agent = agent
|
|
93
|
+
self.policy = policy
|
|
94
|
+
self.audit_log = audit_log
|
|
95
|
+
self.risk_level = risk_level
|
|
96
|
+
self.metadata = dict(metadata or {})
|
|
97
|
+
|
|
98
|
+
def build_action(
|
|
99
|
+
self,
|
|
100
|
+
args: tuple,
|
|
101
|
+
kwargs: dict[str, Any],
|
|
102
|
+
*,
|
|
103
|
+
extra_metadata: dict[str, Any] | None = None,
|
|
104
|
+
) -> ToolAction:
|
|
105
|
+
metadata = dict(self.metadata)
|
|
106
|
+
if extra_metadata:
|
|
107
|
+
metadata.update(extra_metadata)
|
|
108
|
+
metadata.setdefault("environment", self.agent.environment)
|
|
109
|
+
# Surface string arguments so path allowlists can be enforced.
|
|
110
|
+
candidates = list(metadata.get("candidate_paths") or [])
|
|
111
|
+
candidates += _string_inputs(args, kwargs)
|
|
112
|
+
metadata["candidate_paths"] = candidates
|
|
113
|
+
return ToolAction(
|
|
114
|
+
agent_id=self.agent.agent_id,
|
|
115
|
+
tool_name=self.tool_name,
|
|
116
|
+
operation=self.operation,
|
|
117
|
+
input_summary=_summarize_inputs(args, kwargs),
|
|
118
|
+
risk_level=self.risk_level,
|
|
119
|
+
metadata=metadata,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
123
|
+
call_metadata = kwargs.pop(GUARD_METADATA_KWARG, None)
|
|
124
|
+
action = self.build_action(args, kwargs, extra_metadata=call_metadata)
|
|
125
|
+
audit_extra = {"metadata": self._audit_metadata(action)}
|
|
126
|
+
result = self.policy.evaluate(action)
|
|
127
|
+
|
|
128
|
+
if result.decision is Decision.DENY:
|
|
129
|
+
# Audit the denial before refusing. Function never runs.
|
|
130
|
+
self.audit_log.record(action, EVENT_DENIED, reason=result.reason, extra=audit_extra)
|
|
131
|
+
raise PolicyDenied(result.reason, reason=result.reason, action=action)
|
|
132
|
+
|
|
133
|
+
if result.decision is Decision.REQUIRE_APPROVAL:
|
|
134
|
+
# Audit the gate before refusing. Function never runs.
|
|
135
|
+
self.audit_log.record(
|
|
136
|
+
action, EVENT_APPROVAL_REQUIRED, reason=result.reason, extra=audit_extra
|
|
137
|
+
)
|
|
138
|
+
raise ApprovalRequired(result.reason, reason=result.reason, action=action)
|
|
139
|
+
|
|
140
|
+
# Allowed: audit the authorization *before* executing the function.
|
|
141
|
+
self.audit_log.record(action, EVENT_ALLOWED, reason=result.reason, extra=audit_extra)
|
|
142
|
+
try:
|
|
143
|
+
value = self.func(*args, **kwargs)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
self.audit_log.record(
|
|
146
|
+
action, EVENT_EXECUTION_FAILED, error=repr(exc), extra=audit_extra
|
|
147
|
+
)
|
|
148
|
+
raise
|
|
149
|
+
self.audit_log.record(
|
|
150
|
+
action,
|
|
151
|
+
EVENT_EXECUTION_SUCCEEDED,
|
|
152
|
+
result_summary=_truncate(repr(value)),
|
|
153
|
+
extra=audit_extra,
|
|
154
|
+
)
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _audit_metadata(action: ToolAction) -> dict[str, Any]:
|
|
159
|
+
"""User-facing metadata for the audit log (internal keys stripped)."""
|
|
160
|
+
return {k: v for k, v in action.metadata.items() if k not in _INTERNAL_METADATA_KEYS}
|
aegize/identity.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Agent identity primitive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._time import utcnow
|
|
10
|
+
|
|
11
|
+
Environment = str # one of: "dev", "staging", "prod"
|
|
12
|
+
|
|
13
|
+
VALID_ENVIRONMENTS = ("dev", "staging", "prod")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AgentIdentity:
|
|
18
|
+
"""Represents a single AI agent that wants to use tools.
|
|
19
|
+
|
|
20
|
+
Every guarded tool call is attributed to one ``AgentIdentity``. The
|
|
21
|
+
``agent_id`` is the stable key that policies are written against.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
agent_id: str
|
|
25
|
+
name: str
|
|
26
|
+
owner: str
|
|
27
|
+
environment: Environment = "dev"
|
|
28
|
+
created_at: datetime = field(default_factory=utcnow)
|
|
29
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if not self.agent_id:
|
|
33
|
+
raise ValueError("agent_id must be a non-empty string")
|
|
34
|
+
if self.environment not in VALID_ENVIRONMENTS:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"environment must be one of {VALID_ENVIRONMENTS}, got {self.environment!r}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"agent_id": self.agent_id,
|
|
42
|
+
"name": self.name,
|
|
43
|
+
"owner": self.owner,
|
|
44
|
+
"environment": self.environment,
|
|
45
|
+
"created_at": self.created_at.isoformat(),
|
|
46
|
+
"metadata": dict(self.metadata),
|
|
47
|
+
}
|
aegize/policy.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""YAML-backed policy engine.
|
|
2
|
+
|
|
3
|
+
Design goals:
|
|
4
|
+
|
|
5
|
+
* **Default deny.** No matching ``allow`` rule means the action is denied.
|
|
6
|
+
* **Deny wins.** An explicit ``deny`` rule overrides ``require_approval`` and
|
|
7
|
+
``allow`` for the same tool/operation.
|
|
8
|
+
* **Readable rules.** Policies are plain YAML so they can live in version
|
|
9
|
+
control and be reviewed like any other code.
|
|
10
|
+
|
|
11
|
+
Evaluation order for a given action:
|
|
12
|
+
|
|
13
|
+
1. ``deny`` -> :data:`Decision.DENY`
|
|
14
|
+
2. ``require_approval`` -> :data:`Decision.REQUIRE_APPROVAL`
|
|
15
|
+
3. ``allow`` -> :data:`Decision.ALLOW`
|
|
16
|
+
4. otherwise -> default-deny
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .action import RISK_ORDER, ToolAction
|
|
28
|
+
from .exceptions import PolicyLoadError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Decision(str, Enum):
|
|
32
|
+
"""The three possible policy outcomes."""
|
|
33
|
+
|
|
34
|
+
ALLOW = "allow"
|
|
35
|
+
DENY = "deny"
|
|
36
|
+
REQUIRE_APPROVAL = "require_approval"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PolicyResult:
|
|
41
|
+
"""Outcome of evaluating a :class:`ToolAction` against a policy."""
|
|
42
|
+
|
|
43
|
+
decision: Decision
|
|
44
|
+
reason: str
|
|
45
|
+
matched_rule: dict[str, Any] | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def allowed(self) -> bool:
|
|
49
|
+
return self.decision is Decision.ALLOW
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_path(value: str) -> str:
|
|
53
|
+
"""Normalize a path for glob comparison (forward slashes, no leading ``./``)."""
|
|
54
|
+
norm = value.replace("\\", "/")
|
|
55
|
+
while norm.startswith("./"):
|
|
56
|
+
norm = norm[2:]
|
|
57
|
+
return norm
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _path_matches(path: str, pattern: str) -> bool:
|
|
61
|
+
"""Return True if ``path`` matches the glob ``pattern``.
|
|
62
|
+
|
|
63
|
+
``**`` is treated like ``*`` (it matches across directory separators), which
|
|
64
|
+
is sufficient for v0.1 allowlists such as ``./safe_data/**``.
|
|
65
|
+
"""
|
|
66
|
+
return fnmatch.fnmatch(_normalize_path(path), _normalize_path(pattern))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tool_and_operation_match(rule: dict[str, Any], action: ToolAction) -> bool:
|
|
70
|
+
"""Match a rule on tool name and (optionally) operation.
|
|
71
|
+
|
|
72
|
+
A rule with no ``operations`` list matches every operation for that tool.
|
|
73
|
+
"""
|
|
74
|
+
if rule.get("tool") != action.tool_name:
|
|
75
|
+
return False
|
|
76
|
+
operations = rule.get("operations")
|
|
77
|
+
if operations is None:
|
|
78
|
+
return True
|
|
79
|
+
return action.operation in operations
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _candidate_paths(action: ToolAction) -> list[str]:
|
|
83
|
+
"""Collect strings from the action that could represent a filesystem path."""
|
|
84
|
+
candidates: list[str] = []
|
|
85
|
+
explicit = action.metadata.get("path")
|
|
86
|
+
if isinstance(explicit, str):
|
|
87
|
+
candidates.append(explicit)
|
|
88
|
+
for value in action.metadata.get("candidate_paths") or []:
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
candidates.append(value)
|
|
91
|
+
return candidates
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _path_rule_satisfied(rule: dict[str, Any], action: ToolAction) -> bool:
|
|
95
|
+
"""If a rule constrains paths, at least one candidate path must match."""
|
|
96
|
+
patterns = rule.get("paths")
|
|
97
|
+
if patterns is None:
|
|
98
|
+
return True
|
|
99
|
+
candidates = _candidate_paths(action)
|
|
100
|
+
if not candidates:
|
|
101
|
+
return False
|
|
102
|
+
return any(_path_matches(c, p) for c in candidates for p in patterns)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _risk_rule_satisfied(rule: dict[str, Any], action: ToolAction) -> bool:
|
|
106
|
+
"""If a rule sets ``risk_level_max``, the action's risk must not exceed it."""
|
|
107
|
+
rmax = rule.get("risk_level_max")
|
|
108
|
+
if rmax is None:
|
|
109
|
+
return True
|
|
110
|
+
if rmax not in RISK_ORDER:
|
|
111
|
+
raise PolicyLoadError(f"invalid risk_level_max in policy: {rmax!r}")
|
|
112
|
+
return RISK_ORDER[action.risk_level] <= RISK_ORDER[rmax]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PermissionPolicy:
|
|
116
|
+
"""Evaluates :class:`ToolAction` objects against a set of per-agent rules."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, agents: dict[str, Any], *, source: str | None = None) -> None:
|
|
119
|
+
self._agents = agents or {}
|
|
120
|
+
self.source = source
|
|
121
|
+
|
|
122
|
+
# -- construction ----------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_dict(cls, data: dict[str, Any], *, source: str | None = None) -> PermissionPolicy:
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
raise PolicyLoadError("policy root must be a mapping")
|
|
128
|
+
agents = data.get("agents")
|
|
129
|
+
if agents is None:
|
|
130
|
+
raise PolicyLoadError("policy is missing the top-level 'agents' key")
|
|
131
|
+
if not isinstance(agents, dict):
|
|
132
|
+
raise PolicyLoadError("'agents' must be a mapping of agent_id -> rules")
|
|
133
|
+
return cls(agents, source=source)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_yaml(cls, path: str | Path) -> PermissionPolicy:
|
|
137
|
+
try:
|
|
138
|
+
import yaml
|
|
139
|
+
except ImportError as exc: # pragma: no cover - dependency guard
|
|
140
|
+
raise PolicyLoadError("PyYAML is required to load policies from YAML") from exc
|
|
141
|
+
|
|
142
|
+
path = Path(path)
|
|
143
|
+
try:
|
|
144
|
+
text = path.read_text(encoding="utf-8")
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
raise PolicyLoadError(f"could not read policy file: {path}") from exc
|
|
147
|
+
try:
|
|
148
|
+
data = yaml.safe_load(text)
|
|
149
|
+
except yaml.YAMLError as exc:
|
|
150
|
+
raise PolicyLoadError(f"could not parse policy YAML: {path}") from exc
|
|
151
|
+
if data is None:
|
|
152
|
+
raise PolicyLoadError(f"policy file is empty: {path}")
|
|
153
|
+
return cls.from_dict(data, source=str(path))
|
|
154
|
+
|
|
155
|
+
# -- evaluation ------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def evaluate(self, action: ToolAction) -> PolicyResult:
|
|
158
|
+
"""Evaluate an action and return a :class:`PolicyResult`.
|
|
159
|
+
|
|
160
|
+
Unknown agents and tools, and any action with no matching ``allow``
|
|
161
|
+
rule, resolve to :data:`Decision.DENY`.
|
|
162
|
+
"""
|
|
163
|
+
agent_rules = self._agents.get(action.agent_id)
|
|
164
|
+
if agent_rules is None:
|
|
165
|
+
return PolicyResult(
|
|
166
|
+
Decision.DENY,
|
|
167
|
+
reason=f"unknown agent '{action.agent_id}' (default deny)",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# 1. Explicit deny wins outright.
|
|
171
|
+
for rule in agent_rules.get("deny", []) or []:
|
|
172
|
+
if _tool_and_operation_match(rule, action):
|
|
173
|
+
return PolicyResult(
|
|
174
|
+
Decision.DENY,
|
|
175
|
+
reason=f"denied by rule for tool '{action.tool_name}'",
|
|
176
|
+
matched_rule=rule,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# 2. Approval gate.
|
|
180
|
+
for rule in agent_rules.get("require_approval", []) or []:
|
|
181
|
+
if _tool_and_operation_match(rule, action):
|
|
182
|
+
return PolicyResult(
|
|
183
|
+
Decision.REQUIRE_APPROVAL,
|
|
184
|
+
reason=f"approval required for tool '{action.tool_name}'",
|
|
185
|
+
matched_rule=rule,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# 3. Allow (subject to risk and path constraints).
|
|
189
|
+
for rule in agent_rules.get("allow", []) or []:
|
|
190
|
+
if not _tool_and_operation_match(rule, action):
|
|
191
|
+
continue
|
|
192
|
+
if not _risk_rule_satisfied(rule, action):
|
|
193
|
+
continue
|
|
194
|
+
if not _path_rule_satisfied(rule, action):
|
|
195
|
+
continue
|
|
196
|
+
return PolicyResult(
|
|
197
|
+
Decision.ALLOW,
|
|
198
|
+
reason=f"allowed by rule for tool '{action.tool_name}'",
|
|
199
|
+
matched_rule=rule,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# 4. Default deny.
|
|
203
|
+
return PolicyResult(
|
|
204
|
+
Decision.DENY,
|
|
205
|
+
reason=(
|
|
206
|
+
f"no matching allow rule for tool '{action.tool_name}' "
|
|
207
|
+
f"operation '{action.operation}' (default deny)"
|
|
208
|
+
),
|
|
209
|
+
)
|
aegize/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aegize
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Infrastructure for autonomous AI agents: identity, policy, permissions, approval workflows, and audit.
|
|
5
|
+
Project-URL: Homepage, https://aegize.com
|
|
6
|
+
Project-URL: Repository, https://github.com/gggaswint/aegize
|
|
7
|
+
Project-URL: Documentation, https://github.com/gggaswint/aegize#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/gggaswint/aegize/issues
|
|
9
|
+
Author-email: Geoffrey Gaswint <ggaswint@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,ai,approval,audit,governance,identity,infrastructure,llm,permissions,policy
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
<p align="center">
|
|
32
|
+
<picture>
|
|
33
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/gggaswint/aegize/main/assets/logo-dark.png">
|
|
34
|
+
<img src="https://raw.githubusercontent.com/gggaswint/aegize/main/assets/logo-light.png" alt="Aegize" width="150">
|
|
35
|
+
</picture>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
# Aegize
|
|
39
|
+
|
|
40
|
+
Infrastructure for autonomous AI agents.
|
|
41
|
+
|
|
42
|
+
**[Website](https://aegize.com)** • **[GitHub](https://github.com/gggaswint/aegize)**
|
|
43
|
+
|
|
44
|
+
[](https://www.python.org/downloads/)
|
|
45
|
+
[](./LICENSE)
|
|
46
|
+
[](#roadmap)
|
|
47
|
+
[](#roadmap)
|
|
48
|
+
|
|
49
|
+
Aegize is the runtime layer between autonomous AI agents and the tools they use.
|
|
50
|
+
|
|
51
|
+
It provides identity, policy enforcement, permissions, approval workflows, audit
|
|
52
|
+
logging, observability, and runtime governance for every AI action.
|
|
53
|
+
|
|
54
|
+
> Every agent action must have identity, permission, policy enforcement, and audit.
|
|
55
|
+
|
|
56
|
+
<p align="center">
|
|
57
|
+
<img src="https://raw.githubusercontent.com/gggaswint/aegize/main/assets/demo.gif" alt="Aegize demo: an agent makes three tool calls — web search allowed, email approval required, shell command denied — each governed and audited by Aegize" width="1000">
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
## Architecture
|
|
61
|
+
|
|
62
|
+
<p align="center">
|
|
63
|
+
<img src="https://raw.githubusercontent.com/gggaswint/aegize/main/assets/architecture.svg" alt="How Aegize fits into an AI agent stack: AI frameworks send tool calls into the Aegize runtime (identity, policy engine, permissions, approval workflows, audit logging, observability); only allowed actions reach tools. Every AI action passes through Aegize before reaching the outside world." width="900">
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## See it in action
|
|
69
|
+
|
|
70
|
+
One agent attempts three tool calls. Aegize **allows** the web search,
|
|
71
|
+
**holds the email for approval**, **blocks the shell command**, and writes an
|
|
72
|
+
audit record for every attempt.
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
$ python examples/demo_story.py
|
|
76
|
+
|
|
77
|
+
[1] web_search.search query='AI safety companies'
|
|
78
|
+
ALLOWED -> results for 'AI safety companies'
|
|
79
|
+
|
|
80
|
+
[2] email.send to='ceo@example.com'
|
|
81
|
+
APPROVAL REQUIRED -> held for human review (not executed)
|
|
82
|
+
reason: approval required for tool 'email'
|
|
83
|
+
|
|
84
|
+
[3] shell.execute cmd='rm -rf /var/data'
|
|
85
|
+
DENIED -> blocked before execution
|
|
86
|
+
reason: denied by rule for tool 'shell'
|
|
87
|
+
|
|
88
|
+
Audit trail:
|
|
89
|
+
allowed web_search search
|
|
90
|
+
execution_succeeded web_search search
|
|
91
|
+
approval_required email send
|
|
92
|
+
denied shell execute
|
|
93
|
+
|
|
94
|
+
3 actions attempted · 1 allowed · 1 awaiting approval · 1 denied · 4 audit records written
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The gated and denied calls never reach the underlying functions. See
|
|
98
|
+
[Demo](#demo) to run it yourself.
|
|
99
|
+
|
|
100
|
+
## Why Aegize?
|
|
101
|
+
|
|
102
|
+
AI systems are moving from answering questions to taking actions — running
|
|
103
|
+
shells, sending email, moving money, calling internal APIs. A model that only
|
|
104
|
+
returns text is easy to contain. An agent that *acts* is not: it needs an
|
|
105
|
+
identity, scoped permissions, approvals for high-impact operations, and a record
|
|
106
|
+
of everything it did.
|
|
107
|
+
|
|
108
|
+
That governance layer is usually missing today, and the model's own judgment
|
|
109
|
+
stands in for it. Aegize is the runtime infrastructure that fills the gap, so
|
|
110
|
+
organizations can let agents take actions without giving up control or
|
|
111
|
+
visibility. Security is one capability this provides; operability, reviewability,
|
|
112
|
+
and confidence in deployment are the rest.
|
|
113
|
+
|
|
114
|
+
## Project Vision
|
|
115
|
+
|
|
116
|
+
> Every meaningful AI action should pass through trusted runtime infrastructure
|
|
117
|
+
> before reaching the outside world.
|
|
118
|
+
|
|
119
|
+
## What Aegize provides
|
|
120
|
+
|
|
121
|
+
- **`AgentIdentity`** — a durable identity for each agent (owner, environment,
|
|
122
|
+
metadata).
|
|
123
|
+
- **`PermissionPolicy`** — a YAML policy engine that returns `allow`, `deny`, or
|
|
124
|
+
`require_approval`.
|
|
125
|
+
- **`GuardedTool` / `@guarded_tool`** — the enforcement point: wrap any callable
|
|
126
|
+
so it is identified, permissioned, gated, and audited.
|
|
127
|
+
- **`AuditLog`** — an append-only JSONL record of every attempt and outcome.
|
|
128
|
+
- **Typed, dependency-light, and easy to extend.** One runtime dependency
|
|
129
|
+
(PyYAML).
|
|
130
|
+
|
|
131
|
+
## Install
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install aegize
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Or from source (for development):
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
git clone https://github.com/gggaswint/aegize.git
|
|
141
|
+
cd aegize
|
|
142
|
+
pip install -e ".[dev]"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Quickstart
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from aegize import AgentIdentity, PermissionPolicy, GuardedTool, AuditLog
|
|
149
|
+
|
|
150
|
+
agent = AgentIdentity(
|
|
151
|
+
agent_id="research_bot",
|
|
152
|
+
name="Research Bot",
|
|
153
|
+
owner="Geoffrey",
|
|
154
|
+
environment="dev",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
policy = PermissionPolicy.from_yaml("aegize.yaml")
|
|
158
|
+
audit = AuditLog("audit.jsonl")
|
|
159
|
+
|
|
160
|
+
def web_search(query: str) -> str:
|
|
161
|
+
return f"searched: {query}"
|
|
162
|
+
|
|
163
|
+
safe_web_search = GuardedTool(
|
|
164
|
+
tool_name="web_search",
|
|
165
|
+
operation="search",
|
|
166
|
+
func=web_search,
|
|
167
|
+
agent=agent,
|
|
168
|
+
policy=policy,
|
|
169
|
+
audit_log=audit,
|
|
170
|
+
risk_level="low",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
result = safe_web_search("AI safety companies")
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
If the policy allows the action, the function runs and two audit records are
|
|
177
|
+
written (authorization + result). If not, Aegize raises `PolicyDenied` or
|
|
178
|
+
`ApprovalRequired` and the function never executes.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from aegize import PolicyDenied, ApprovalRequired
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
safe_web_search("AI safety companies")
|
|
185
|
+
except ApprovalRequired as exc:
|
|
186
|
+
# route to a human approval workflow
|
|
187
|
+
...
|
|
188
|
+
except PolicyDenied as exc:
|
|
189
|
+
# blocked outright
|
|
190
|
+
...
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Decorator quickstart (v0.2)
|
|
194
|
+
|
|
195
|
+
You don't have to wrap every function by hand. Declare a tool once with
|
|
196
|
+
`@guarded_tool`, bundle your agent/policy/audit into a `GuardContext`, and bind
|
|
197
|
+
them together when you have a context.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from aegize import (
|
|
201
|
+
AgentIdentity, PermissionPolicy, AuditLog,
|
|
202
|
+
GuardContext, guarded_tool, guard, ApprovalRequired,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@guarded_tool(tool_name="email", operation="send", risk_level="high")
|
|
206
|
+
def send_email(to: str, body: str) -> str:
|
|
207
|
+
... # your real implementation
|
|
208
|
+
|
|
209
|
+
ctx = GuardContext(
|
|
210
|
+
agent=AgentIdentity(agent_id="research_bot", name="Research Bot", owner="Geoffrey"),
|
|
211
|
+
policy=PermissionPolicy.from_yaml("aegize.yaml"),
|
|
212
|
+
audit_log=AuditLog("audit.jsonl"),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Bind to a context -> a plain, signature-preserving callable you can register.
|
|
216
|
+
guarded_send = guard(send_email, context=ctx)
|
|
217
|
+
# e.g. server.add_tool(guarded_send) # MCP / any tool registry
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
guarded_send("ceo@example.com", "Q3 numbers")
|
|
221
|
+
except ApprovalRequired:
|
|
222
|
+
... # gated for human approval; send_email never ran
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`guard()` returns a callable that preserves the original `__name__`,
|
|
226
|
+
docstring, and signature, so tool registries (including MCP servers) that
|
|
227
|
+
introspect functions keep working.
|
|
228
|
+
|
|
229
|
+
**Default context.** Inside a `with ctx:` block (or after `ctx.activate()`),
|
|
230
|
+
decorated tools can be called directly:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
with ctx:
|
|
234
|
+
send_email("ceo@example.com", "Q3 numbers") # uses the active context
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Per-call metadata.** Pass `guard_metadata=` to any guarded call to attach
|
|
238
|
+
context for policy decisions (e.g. a path for an allowlist) and the audit log.
|
|
239
|
+
It is stripped before your function runs:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
guarded_read("report", guard_metadata={"path": "./safe_data/report.txt"})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Both styles are fully supported — use `GuardedTool(...)` directly when you want
|
|
246
|
+
explicit objects, or the decorator when you want ergonomics. They share the same
|
|
247
|
+
enforcement and audit code.
|
|
248
|
+
|
|
249
|
+
## Policy YAML
|
|
250
|
+
|
|
251
|
+
Policies are per-agent. Each agent has `allow`, `require_approval`, and `deny`
|
|
252
|
+
sections. Evaluation order is **deny → require_approval → allow → default-deny**,
|
|
253
|
+
so an explicit `deny` always wins and anything unlisted is denied.
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
agents:
|
|
257
|
+
research_bot:
|
|
258
|
+
allow:
|
|
259
|
+
- tool: web_search
|
|
260
|
+
operations: ["search"]
|
|
261
|
+
risk_level_max: medium # block this rule above 'medium' risk
|
|
262
|
+
- tool: file_reader
|
|
263
|
+
operations: ["read"]
|
|
264
|
+
paths:
|
|
265
|
+
- "./safe_data/**" # only inside the allowlisted path
|
|
266
|
+
|
|
267
|
+
require_approval:
|
|
268
|
+
- tool: email
|
|
269
|
+
operations: ["send"]
|
|
270
|
+
- tool: shell
|
|
271
|
+
operations: ["execute"]
|
|
272
|
+
|
|
273
|
+
deny:
|
|
274
|
+
- tool: payments
|
|
275
|
+
operations: ["charge"]
|
|
276
|
+
- tool: shell
|
|
277
|
+
operations: ["rm", "delete"]
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Rule fields:
|
|
281
|
+
|
|
282
|
+
| Field | Applies to | Meaning |
|
|
283
|
+
| ---------------- | ----------------------- | ----------------------------------------------------------------- |
|
|
284
|
+
| `tool` | all | Tool name to match. |
|
|
285
|
+
| `operations` | all | Operations the rule covers. Omit to match every operation. |
|
|
286
|
+
| `risk_level_max` | `allow` | Highest risk this rule permits (`low`…`critical`). |
|
|
287
|
+
| `paths` | `allow` | Glob allowlist; a string argument must match one of these. |
|
|
288
|
+
|
|
289
|
+
> **Path matching:** when a rule has `paths`, Aegize checks the string
|
|
290
|
+
> arguments of the call (and `metadata["path"]`) against the glob patterns. A
|
|
291
|
+
> call with no matching path is denied.
|
|
292
|
+
|
|
293
|
+
## Audit log
|
|
294
|
+
|
|
295
|
+
Every attempt is appended to a JSONL file — one self-contained JSON object per
|
|
296
|
+
line, easy to tail, `grep`, or ship to a SIEM. A single allowed call:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{"timestamp": "2026-06-27T18:00:00+00:00", "event": "allowed", "agent_id": "research_bot", "tool_name": "web_search", "operation": "search", "risk_level": "low", "input_summary": "'AI safety companies'", "reason": "allowed by rule for tool 'web_search'"}
|
|
300
|
+
{"timestamp": "2026-06-27T18:00:00+00:00", "event": "execution_succeeded", "agent_id": "research_bot", "tool_name": "web_search", "operation": "search", "risk_level": "low", "result_summary": "'searched: AI safety companies'"}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Events: `allowed`, `denied`, `approval_required`, `execution_succeeded`,
|
|
304
|
+
`execution_failed`. The authorization decision is always written **before** the
|
|
305
|
+
function runs; the result is written after. Reading the log back is one call:
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
for record in audit.read_all():
|
|
309
|
+
print(record["event"], record["tool_name"], record["operation"])
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Demo
|
|
313
|
+
|
|
314
|
+
The 60-second story — one agent, three tool calls, three outcomes, all audited:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
python examples/demo_story.py
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
It runs the [`See it in action`](#see-it-in-action) flow above against
|
|
321
|
+
[`examples/demo_policy.yaml`](./examples/demo_policy.yaml) and prints the path to
|
|
322
|
+
the audit log it wrote.
|
|
323
|
+
|
|
324
|
+
## Examples
|
|
325
|
+
|
|
326
|
+
More runnable scripts live in [`examples/`](./examples):
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
python examples/basic_allow.py # allowed web_search runs
|
|
330
|
+
python examples/denied_shell.py # denied shell command is blocked
|
|
331
|
+
python examples/approval_email.py # email send raises ApprovalRequired
|
|
332
|
+
python examples/decorator_usage.py # @guarded_tool + GuardContext (v0.2)
|
|
333
|
+
python examples/demo_story.py # the full allow / approve / deny story
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Enforcement guarantees
|
|
337
|
+
|
|
338
|
+
- **Default deny.** No matching `allow` rule means the action is denied.
|
|
339
|
+
- **Deny wins.** An explicit `deny` overrides `require_approval` and `allow`.
|
|
340
|
+
- **Gated actions never execute.** `deny` and `require_approval` raise before
|
|
341
|
+
the wrapped function is called.
|
|
342
|
+
- **Audit-first.** The decision is recorded before any execution is attempted;
|
|
343
|
+
the result is recorded after.
|
|
344
|
+
|
|
345
|
+
## Project documents
|
|
346
|
+
|
|
347
|
+
The operating documents for Aegize — useful for contributors and for
|
|
348
|
+
understanding where the project is headed.
|
|
349
|
+
|
|
350
|
+
**Direction**
|
|
351
|
+
|
|
352
|
+
- [Vision](./docs/vision.md) — the thesis, the problem, and the long-term ambition.
|
|
353
|
+
- [Roadmap](./docs/roadmap.md) — from the current SDK to runtime governance.
|
|
354
|
+
- [Architecture](./docs/architecture.md) — primitives, runtime flow, and trust model.
|
|
355
|
+
|
|
356
|
+
**Operating**
|
|
357
|
+
|
|
358
|
+
- [Principles](./docs/principles.md) — the engineering and product tie-breakers.
|
|
359
|
+
- [Anti-goals](./docs/anti-goals.md) — what Aegize is deliberately not.
|
|
360
|
+
- [Brand](./docs/brand.md) — positioning, messaging, and visual language.
|
|
361
|
+
|
|
362
|
+
**Process & record**
|
|
363
|
+
|
|
364
|
+
- [Decisions](./docs/decisions.md) — the record of why things are the way they are.
|
|
365
|
+
- [Open questions](./docs/questions.md) — unresolved product and architecture questions.
|
|
366
|
+
- [RFCs](./rfcs/README.md) — how significant changes are proposed and recorded.
|
|
367
|
+
- [Launch checklist](./docs/launch-checklist.md) — what's done and what's left to launch.
|
|
368
|
+
- [Next steps](./docs/next-steps.md) — the focused two-week execution plan.
|
|
369
|
+
- [CLAUDE.md](./CLAUDE.md) — operating instructions for AI coding sessions.
|
|
370
|
+
|
|
371
|
+
## Roadmap
|
|
372
|
+
|
|
373
|
+
- ~~`@guarded_tool` decorator + `GuardContext` ergonomics.~~ ✅ v0.2
|
|
374
|
+
- Policy schema validation and a `aegize lint` CLI.
|
|
375
|
+
- First-class adapters for popular agent frameworks (a thin MCP registration
|
|
376
|
+
helper on top of the v0.2 `guard()` callable).
|
|
377
|
+
- Pluggable approval backends (Slack, webhook, queue) for `require_approval`.
|
|
378
|
+
- Pluggable audit sinks (stdout, syslog, S3, SIEM) beyond local JSONL.
|
|
379
|
+
- Per-environment policy overlays (`dev` / `staging` / `prod`).
|
|
380
|
+
- Rate limits and budget/quota controls per agent and tool.
|
|
381
|
+
- Signed, tamper-evident audit logs.
|
|
382
|
+
|
|
383
|
+
## Development
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
pip install -e ".[dev]"
|
|
387
|
+
pytest # run the test suite
|
|
388
|
+
ruff check . # lint
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
CI runs the same `pytest` + `ruff` checks on every push and pull request across
|
|
392
|
+
Python 3.9–3.12.
|
|
393
|
+
|
|
394
|
+
## Contributing
|
|
395
|
+
|
|
396
|
+
Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the dev
|
|
397
|
+
setup, the project's scope and design principles, and the bar for a mergeable
|
|
398
|
+
change.
|
|
399
|
+
|
|
400
|
+
> **Before making major changes, read [CLAUDE.md](./CLAUDE.md) and the project
|
|
401
|
+
> documents in [`docs/`](./docs).** They are the source of truth for Aegize's
|
|
402
|
+
> direction, positioning, and design. (`python scripts/context_check.py` confirms
|
|
403
|
+
> they're present.)
|
|
404
|
+
|
|
405
|
+
## Reporting vulnerabilities
|
|
406
|
+
|
|
407
|
+
Aegize governs what agents are allowed to do, so we treat weaknesses in it
|
|
408
|
+
seriously. Please report vulnerabilities privately — see
|
|
409
|
+
[SECURITY.md](./SECURITY.md). Do not open a public issue for a suspected
|
|
410
|
+
vulnerability.
|
|
411
|
+
|
|
412
|
+
## License
|
|
413
|
+
|
|
414
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
aegize/__init__.py,sha256=Bx37v6_UqNEJTiEQQhbapvqOdSwBsj09pfG26seM540,1445
|
|
2
|
+
aegize/_time.py,sha256=SWzO6a_wFobnUcNNt-G83xC8tL198twEGinmLTV9whg,270
|
|
3
|
+
aegize/action.py,sha256=PrER3F5wNDgMchwi6auyjkFU5XETlGf3g7RgbyhiJfs,1650
|
|
4
|
+
aegize/audit.py,sha256=-ySHTlsie2poXqkXc-rkEFuAfOidSLCSHYPnRv8T3tQ,2764
|
|
5
|
+
aegize/context.py,sha256=U7eYRHGFog6SPWk6Eu-08LI7Rny99mx-5DSDq469JsA,2412
|
|
6
|
+
aegize/decorators.py,sha256=YNoLyKswX2ilNNcg05Y-WhVb8f6Ls-0HkX73rNzPjuU,4566
|
|
7
|
+
aegize/exceptions.py,sha256=6ZQNLQiO0wrVfR3vGwf_VQLZ6Koa8m94vAS0b3JusFs,1407
|
|
8
|
+
aegize/guarded_tool.py,sha256=EYlKXf8QcIBvuDvQM1QjLP40uiqTtDFtBP8eRh69GmE,5791
|
|
9
|
+
aegize/identity.py,sha256=iYlm4RS5jVLqWeQsc-1e4FW65obFv8d1DCeOdO4RPcE,1386
|
|
10
|
+
aegize/policy.py,sha256=z4Yubn2xqXFJmQsL8ngR4fS4Ja0EH2Kb07DfqO2LHk8,7342
|
|
11
|
+
aegize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
aegize-0.2.0.dist-info/METADATA,sha256=I0R3nteMbeO2RYdvOD1mxew81XLZor2KLTdavFCoOKU,15404
|
|
13
|
+
aegize-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
aegize-0.2.0.dist-info/licenses/LICENSE,sha256=-TosaVc9rSOwa4-p2Td4X3C5lMxLaJM8Cff0X-Rlb7M,1065
|
|
15
|
+
aegize-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geoffrey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|