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 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}