agentlock 1.0.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.
- agentlock/__init__.py +150 -0
- agentlock/audit.py +241 -0
- agentlock/auth_providers/__init__.py +69 -0
- agentlock/cli.py +308 -0
- agentlock/decorators.py +182 -0
- agentlock/exceptions.py +142 -0
- agentlock/gate.py +481 -0
- agentlock/integrations/__init__.py +25 -0
- agentlock/integrations/autogen.py +208 -0
- agentlock/integrations/crewai.py +228 -0
- agentlock/integrations/fastapi.py +324 -0
- agentlock/integrations/flask.py +285 -0
- agentlock/integrations/langchain.py +326 -0
- agentlock/integrations/mcp.py +212 -0
- agentlock/policy.py +196 -0
- agentlock/py.typed +0 -0
- agentlock/rate_limit.py +101 -0
- agentlock/redaction.py +124 -0
- agentlock/schema.py +173 -0
- agentlock/session.py +130 -0
- agentlock/token.py +172 -0
- agentlock/types.py +148 -0
- agentlock-1.0.0.dist-info/METADATA +349 -0
- agentlock-1.0.0.dist-info/RECORD +28 -0
- agentlock-1.0.0.dist-info/WHEEL +4 -0
- agentlock-1.0.0.dist-info/entry_points.txt +2 -0
- agentlock-1.0.0.dist-info/licenses/LICENSE +190 -0
- agentlock-1.0.0.dist-info/licenses/NOTICE +14 -0
agentlock/__init__.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""AgentLock — Authorization framework for AI agent tool calls.
|
|
2
|
+
|
|
3
|
+
Your AI agent needs a login screen. AgentLock is that login screen.
|
|
4
|
+
|
|
5
|
+
AgentLock defines an open standard for authorization in AI agent systems.
|
|
6
|
+
It introduces a permissions schema that any tool can implement, any agent
|
|
7
|
+
framework can enforce, and any security team can audit.
|
|
8
|
+
|
|
9
|
+
Quick start::
|
|
10
|
+
|
|
11
|
+
from agentlock import AuthorizationGate, AgentLockPermissions, agentlock
|
|
12
|
+
|
|
13
|
+
gate = AuthorizationGate()
|
|
14
|
+
|
|
15
|
+
# Protect a tool in one line
|
|
16
|
+
@agentlock(gate, risk_level="high", allowed_roles=["admin"])
|
|
17
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
18
|
+
return f"Email sent to {to}"
|
|
19
|
+
|
|
20
|
+
# Or register manually
|
|
21
|
+
gate.register_tool("read_db", AgentLockPermissions(
|
|
22
|
+
risk_level="medium",
|
|
23
|
+
requires_auth=True,
|
|
24
|
+
allowed_roles=["analyst", "admin"],
|
|
25
|
+
scope={"data_boundary": "authenticated_user_only", "max_records": 100},
|
|
26
|
+
))
|
|
27
|
+
|
|
28
|
+
# Authorize every call
|
|
29
|
+
result = gate.authorize("read_db", user_id="alice", role="analyst")
|
|
30
|
+
if result.allowed:
|
|
31
|
+
output = gate.execute("read_db", my_db_func, token=result.token)
|
|
32
|
+
|
|
33
|
+
Copyright 2026 David Grice (AgentShield — agent-shield.com)
|
|
34
|
+
SPDX-License-Identifier: Apache-2.0
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__version__ = "1.0.0"
|
|
38
|
+
|
|
39
|
+
from agentlock.audit import AuditLogger, AuditRecord, FileAuditBackend, InMemoryAuditBackend
|
|
40
|
+
from agentlock.decorators import agentlock
|
|
41
|
+
from agentlock.exceptions import (
|
|
42
|
+
AgentLockError,
|
|
43
|
+
ApprovalRequiredError,
|
|
44
|
+
AuthenticationRequiredError,
|
|
45
|
+
ConfigurationError,
|
|
46
|
+
DeniedError,
|
|
47
|
+
InsufficientRoleError,
|
|
48
|
+
RateLimitedError,
|
|
49
|
+
SchemaValidationError,
|
|
50
|
+
ScopeViolationError,
|
|
51
|
+
SessionExpiredError,
|
|
52
|
+
TokenError,
|
|
53
|
+
TokenExpiredError,
|
|
54
|
+
TokenInvalidError,
|
|
55
|
+
TokenReplayedError,
|
|
56
|
+
)
|
|
57
|
+
from agentlock.gate import AuthorizationGate, AuthResult
|
|
58
|
+
from agentlock.policy import PolicyDecision, PolicyEngine, RequestContext
|
|
59
|
+
from agentlock.rate_limit import RateLimiter
|
|
60
|
+
from agentlock.redaction import RedactionEngine, RedactionResult
|
|
61
|
+
from agentlock.schema import (
|
|
62
|
+
SCHEMA_VERSION,
|
|
63
|
+
AgentLockPermissions,
|
|
64
|
+
AuditConfig,
|
|
65
|
+
DataPolicyConfig,
|
|
66
|
+
HumanApprovalConfig,
|
|
67
|
+
RateLimitConfig,
|
|
68
|
+
ScopeConfig,
|
|
69
|
+
SessionConfig,
|
|
70
|
+
ToolDefinition,
|
|
71
|
+
)
|
|
72
|
+
from agentlock.session import Session, SessionStore
|
|
73
|
+
from agentlock.token import ExecutionToken, TokenStore
|
|
74
|
+
from agentlock.types import (
|
|
75
|
+
ApprovalChannel,
|
|
76
|
+
ApprovalThreshold,
|
|
77
|
+
AuditLogLevel,
|
|
78
|
+
AuthMethod,
|
|
79
|
+
DataBoundary,
|
|
80
|
+
DataClassification,
|
|
81
|
+
DenialReason,
|
|
82
|
+
RecipientPolicy,
|
|
83
|
+
RedactionMode,
|
|
84
|
+
RiskLevel,
|
|
85
|
+
TokenStatus,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
# Core
|
|
90
|
+
"AuthorizationGate",
|
|
91
|
+
"AuthResult",
|
|
92
|
+
"AgentLockPermissions",
|
|
93
|
+
"ToolDefinition",
|
|
94
|
+
"agentlock",
|
|
95
|
+
# Schema components
|
|
96
|
+
"ScopeConfig",
|
|
97
|
+
"RateLimitConfig",
|
|
98
|
+
"DataPolicyConfig",
|
|
99
|
+
"SessionConfig",
|
|
100
|
+
"AuditConfig",
|
|
101
|
+
"HumanApprovalConfig",
|
|
102
|
+
"SCHEMA_VERSION",
|
|
103
|
+
# Policy
|
|
104
|
+
"PolicyEngine",
|
|
105
|
+
"PolicyDecision",
|
|
106
|
+
"RequestContext",
|
|
107
|
+
# Tokens
|
|
108
|
+
"ExecutionToken",
|
|
109
|
+
"TokenStore",
|
|
110
|
+
# Sessions
|
|
111
|
+
"Session",
|
|
112
|
+
"SessionStore",
|
|
113
|
+
# Audit
|
|
114
|
+
"AuditLogger",
|
|
115
|
+
"AuditRecord",
|
|
116
|
+
"FileAuditBackend",
|
|
117
|
+
"InMemoryAuditBackend",
|
|
118
|
+
# Rate limiting
|
|
119
|
+
"RateLimiter",
|
|
120
|
+
# Redaction
|
|
121
|
+
"RedactionEngine",
|
|
122
|
+
"RedactionResult",
|
|
123
|
+
# Enums
|
|
124
|
+
"RiskLevel",
|
|
125
|
+
"AuthMethod",
|
|
126
|
+
"DataClassification",
|
|
127
|
+
"DataBoundary",
|
|
128
|
+
"RecipientPolicy",
|
|
129
|
+
"RedactionMode",
|
|
130
|
+
"AuditLogLevel",
|
|
131
|
+
"ApprovalThreshold",
|
|
132
|
+
"ApprovalChannel",
|
|
133
|
+
"DenialReason",
|
|
134
|
+
"TokenStatus",
|
|
135
|
+
# Exceptions
|
|
136
|
+
"AgentLockError",
|
|
137
|
+
"DeniedError",
|
|
138
|
+
"AuthenticationRequiredError",
|
|
139
|
+
"InsufficientRoleError",
|
|
140
|
+
"ScopeViolationError",
|
|
141
|
+
"RateLimitedError",
|
|
142
|
+
"SessionExpiredError",
|
|
143
|
+
"ApprovalRequiredError",
|
|
144
|
+
"TokenError",
|
|
145
|
+
"TokenInvalidError",
|
|
146
|
+
"TokenExpiredError",
|
|
147
|
+
"TokenReplayedError",
|
|
148
|
+
"SchemaValidationError",
|
|
149
|
+
"ConfigurationError",
|
|
150
|
+
]
|
agentlock/audit.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Audit logging — every tool call generates an audit record.
|
|
2
|
+
|
|
3
|
+
Audit is not optional in AgentLock. The default backend writes structured
|
|
4
|
+
JSON to a file. Production deployments should use the ``AuditBackend``
|
|
5
|
+
protocol to integrate with SIEM, CloudWatch, Datadog, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from agentlock.types import AuditId, AuditLogLevel
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("agentlock.audit")
|
|
21
|
+
|
|
22
|
+
__all__ = ["AuditRecord", "AuditLogger", "AuditBackend", "FileAuditBackend"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _generate_audit_id() -> AuditId:
|
|
26
|
+
ts = time.strftime("%Y-%m-%d", time.gmtime())
|
|
27
|
+
seq = secrets.token_hex(4)
|
|
28
|
+
return f"agentlock-{ts}-{seq}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class AuditRecord:
|
|
33
|
+
"""A single audit entry."""
|
|
34
|
+
|
|
35
|
+
audit_id: AuditId = field(default_factory=_generate_audit_id)
|
|
36
|
+
timestamp: float = field(default_factory=time.time)
|
|
37
|
+
tool_name: str = ""
|
|
38
|
+
user_id: str = ""
|
|
39
|
+
role: str = ""
|
|
40
|
+
action: str = "" # "allowed", "denied", "error"
|
|
41
|
+
reason: str = ""
|
|
42
|
+
risk_level: str = ""
|
|
43
|
+
parameters: dict[str, Any] | None = None
|
|
44
|
+
response_summary: str = ""
|
|
45
|
+
token_id: str = ""
|
|
46
|
+
session_id: str = ""
|
|
47
|
+
duration_ms: float = 0.0
|
|
48
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
d = asdict(self)
|
|
52
|
+
if d["parameters"] is None:
|
|
53
|
+
del d["parameters"]
|
|
54
|
+
return d
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@runtime_checkable
|
|
58
|
+
class AuditBackend(Protocol):
|
|
59
|
+
"""Protocol for pluggable audit storage."""
|
|
60
|
+
|
|
61
|
+
def write(self, record: AuditRecord) -> None: ...
|
|
62
|
+
def query(
|
|
63
|
+
self,
|
|
64
|
+
tool_name: str | None = None,
|
|
65
|
+
user_id: str | None = None,
|
|
66
|
+
since: float | None = None,
|
|
67
|
+
limit: int = 100,
|
|
68
|
+
) -> list[AuditRecord]: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FileAuditBackend:
|
|
72
|
+
"""Append-only JSON-lines audit log.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: File path for the audit log. Created if missing.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
79
|
+
if path is None:
|
|
80
|
+
path = Path.home() / ".agentlock" / "audit.jsonl"
|
|
81
|
+
self._path = Path(path)
|
|
82
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
def write(self, record: AuditRecord) -> None:
|
|
85
|
+
with open(self._path, "a") as f:
|
|
86
|
+
f.write(json.dumps(record.to_dict(), default=str) + "\n")
|
|
87
|
+
|
|
88
|
+
def query(
|
|
89
|
+
self,
|
|
90
|
+
tool_name: str | None = None,
|
|
91
|
+
user_id: str | None = None,
|
|
92
|
+
since: float | None = None,
|
|
93
|
+
limit: int = 100,
|
|
94
|
+
) -> list[AuditRecord]:
|
|
95
|
+
if not self._path.exists():
|
|
96
|
+
return []
|
|
97
|
+
results: list[AuditRecord] = []
|
|
98
|
+
with open(self._path) as f:
|
|
99
|
+
for line in f:
|
|
100
|
+
line = line.strip()
|
|
101
|
+
if not line:
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
d = json.loads(line)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
if tool_name and d.get("tool_name") != tool_name:
|
|
108
|
+
continue
|
|
109
|
+
if user_id and d.get("user_id") != user_id:
|
|
110
|
+
continue
|
|
111
|
+
if since and d.get("timestamp", 0) < since:
|
|
112
|
+
continue
|
|
113
|
+
results.append(AuditRecord(**{
|
|
114
|
+
k: v for k, v in d.items()
|
|
115
|
+
if k in AuditRecord.__dataclass_fields__
|
|
116
|
+
}))
|
|
117
|
+
if len(results) >= limit:
|
|
118
|
+
break
|
|
119
|
+
return results
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class InMemoryAuditBackend:
|
|
123
|
+
"""In-memory audit backend for testing."""
|
|
124
|
+
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self.records: list[AuditRecord] = []
|
|
127
|
+
|
|
128
|
+
def write(self, record: AuditRecord) -> None:
|
|
129
|
+
self.records.append(record)
|
|
130
|
+
|
|
131
|
+
def query(
|
|
132
|
+
self,
|
|
133
|
+
tool_name: str | None = None,
|
|
134
|
+
user_id: str | None = None,
|
|
135
|
+
since: float | None = None,
|
|
136
|
+
limit: int = 100,
|
|
137
|
+
) -> list[AuditRecord]:
|
|
138
|
+
results = []
|
|
139
|
+
for r in self.records:
|
|
140
|
+
if tool_name and r.tool_name != tool_name:
|
|
141
|
+
continue
|
|
142
|
+
if user_id and r.user_id != user_id:
|
|
143
|
+
continue
|
|
144
|
+
if since and r.timestamp < since:
|
|
145
|
+
continue
|
|
146
|
+
results.append(r)
|
|
147
|
+
if len(results) >= limit:
|
|
148
|
+
break
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AuditLogger:
|
|
153
|
+
"""Central audit logger.
|
|
154
|
+
|
|
155
|
+
Delegates to a pluggable backend. Filters records based on the
|
|
156
|
+
tool's configured ``log_level``.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, backend: AuditBackend | None = None) -> None:
|
|
160
|
+
self._backend = backend or FileAuditBackend()
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def backend(self) -> AuditBackend:
|
|
164
|
+
return self._backend
|
|
165
|
+
|
|
166
|
+
def log(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
tool_name: str,
|
|
170
|
+
user_id: str = "",
|
|
171
|
+
role: str = "",
|
|
172
|
+
action: str,
|
|
173
|
+
reason: str = "",
|
|
174
|
+
risk_level: str = "",
|
|
175
|
+
parameters: dict[str, Any] | None = None,
|
|
176
|
+
response_summary: str = "",
|
|
177
|
+
token_id: str = "",
|
|
178
|
+
session_id: str = "",
|
|
179
|
+
duration_ms: float = 0.0,
|
|
180
|
+
log_level: AuditLogLevel = AuditLogLevel.STANDARD,
|
|
181
|
+
include_parameters: bool = True,
|
|
182
|
+
metadata: dict[str, Any] | None = None,
|
|
183
|
+
) -> AuditRecord:
|
|
184
|
+
"""Create and persist an audit record.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
tool_name: Name of the tool being invoked.
|
|
188
|
+
user_id: Authenticated user identity.
|
|
189
|
+
role: Role used for authorization.
|
|
190
|
+
action: "allowed", "denied", or "error".
|
|
191
|
+
reason: Denial reason or error description.
|
|
192
|
+
risk_level: Tool's risk classification.
|
|
193
|
+
parameters: Call parameters (omitted if include_parameters is False).
|
|
194
|
+
response_summary: Truncated response for full logging.
|
|
195
|
+
token_id: Execution token identifier.
|
|
196
|
+
session_id: Session identifier.
|
|
197
|
+
duration_ms: Execution duration in milliseconds.
|
|
198
|
+
log_level: The tool's configured audit level.
|
|
199
|
+
include_parameters: Whether to include parameters in the record.
|
|
200
|
+
metadata: Additional context.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The created audit record.
|
|
204
|
+
"""
|
|
205
|
+
record = AuditRecord(
|
|
206
|
+
tool_name=tool_name,
|
|
207
|
+
user_id=user_id,
|
|
208
|
+
role=role,
|
|
209
|
+
action=action,
|
|
210
|
+
reason=reason,
|
|
211
|
+
risk_level=risk_level,
|
|
212
|
+
token_id=token_id,
|
|
213
|
+
session_id=session_id,
|
|
214
|
+
duration_ms=duration_ms,
|
|
215
|
+
metadata=metadata or {},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Filter fields based on log level
|
|
219
|
+
if log_level == AuditLogLevel.MINIMAL:
|
|
220
|
+
# name + timestamp + outcome only
|
|
221
|
+
record.parameters = None
|
|
222
|
+
record.response_summary = ""
|
|
223
|
+
record.user_id = ""
|
|
224
|
+
record.role = ""
|
|
225
|
+
elif log_level == AuditLogLevel.STANDARD:
|
|
226
|
+
# + identity + scope
|
|
227
|
+
record.parameters = None
|
|
228
|
+
record.response_summary = ""
|
|
229
|
+
else:
|
|
230
|
+
# FULL — include everything
|
|
231
|
+
if include_parameters:
|
|
232
|
+
record.parameters = parameters
|
|
233
|
+
record.response_summary = response_summary
|
|
234
|
+
|
|
235
|
+
self._backend.write(record)
|
|
236
|
+
logger.debug("audit: %s %s %s → %s", tool_name, user_id, action, record.audit_id)
|
|
237
|
+
return record
|
|
238
|
+
|
|
239
|
+
def query(self, **kwargs: Any) -> list[AuditRecord]:
|
|
240
|
+
"""Query audit records. Delegates to backend."""
|
|
241
|
+
return self._backend.query(**kwargs)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pluggable authentication provider backends.
|
|
2
|
+
|
|
3
|
+
AgentLock does not perform authentication itself — it delegates to
|
|
4
|
+
external identity providers. These adapters standardize the interface.
|
|
5
|
+
|
|
6
|
+
Authentication MUST occur out-of-band from the agent conversation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class AuthProvider(Protocol):
|
|
16
|
+
"""Protocol for authentication providers.
|
|
17
|
+
|
|
18
|
+
Implementations handle the out-of-band authentication flow and return
|
|
19
|
+
verified identity information to the authorization gate.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def initiate_auth(
|
|
23
|
+
self, user_hint: str = "", method: str = "oauth2"
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Start an authentication flow.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
user_hint: Optional hint (email, username) to pre-fill.
|
|
29
|
+
method: Authentication method to use.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Dict with at least ``auth_url`` or ``challenge_id`` for the
|
|
33
|
+
out-of-band flow.
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def verify(self, token_or_code: str) -> dict[str, Any] | None:
|
|
38
|
+
"""Verify an authentication response.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
token_or_code: The token, code, or response from the auth flow.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dict with ``user_id``, ``role``, and optional ``metadata`` if
|
|
45
|
+
verification succeeds. None if verification fails.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StaticAuthProvider:
|
|
51
|
+
"""Static auth provider for development and testing.
|
|
52
|
+
|
|
53
|
+
Maps user IDs to roles directly. NOT for production use.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, users: dict[str, str]) -> None:
|
|
57
|
+
self._users = users # user_id → role
|
|
58
|
+
|
|
59
|
+
def initiate_auth(
|
|
60
|
+
self, user_hint: str = "", method: str = "oauth2"
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
return {"type": "static", "message": "No auth flow needed in static mode."}
|
|
63
|
+
|
|
64
|
+
def verify(self, token_or_code: str) -> dict[str, Any] | None:
|
|
65
|
+
# token_or_code is treated as user_id
|
|
66
|
+
role = self._users.get(token_or_code)
|
|
67
|
+
if role is None:
|
|
68
|
+
return None
|
|
69
|
+
return {"user_id": token_or_code, "role": role}
|