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 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
+ [![Python](https://img.shields.io/badge/python-3.9%2B-3776ab?style=flat-square)](https://www.python.org/downloads/)
45
+ [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950?style=flat-square)](./LICENSE)
46
+ [![Status](https://img.shields.io/badge/status-alpha-d29922?style=flat-square)](#roadmap)
47
+ [![Version](https://img.shields.io/badge/version-0.2.0-5b9dff?style=flat-square)](#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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.