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/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""AgentLock CLI — validate schemas, inspect audit logs, manage tools.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
agentlock validate tool.json # Validate a tool definition
|
|
6
|
+
agentlock audit --tool send_email # Query audit logs
|
|
7
|
+
agentlock schema # Print the JSON schema
|
|
8
|
+
agentlock init # Generate a starter agentlock.json
|
|
9
|
+
agentlock inspect tool.json # Display permissions summary
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from agentlock import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
23
|
+
"""Validate a tool definition file against the AgentLock schema."""
|
|
24
|
+
from pydantic import ValidationError
|
|
25
|
+
|
|
26
|
+
from agentlock.schema import AgentLockPermissions, ToolDefinition
|
|
27
|
+
|
|
28
|
+
path = Path(args.file)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
print(f"Error: file not found: {path}", file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(path.read_text())
|
|
35
|
+
except json.JSONDecodeError as e:
|
|
36
|
+
print(f"Error: invalid JSON: {e}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
|
|
41
|
+
# If the file is a full tool definition
|
|
42
|
+
if "name" in data:
|
|
43
|
+
try:
|
|
44
|
+
tool = ToolDefinition(**data)
|
|
45
|
+
perms = tool.agentlock
|
|
46
|
+
except ValidationError as e:
|
|
47
|
+
errors.extend(str(err) for err in e.errors())
|
|
48
|
+
elif "agentlock" in data:
|
|
49
|
+
try:
|
|
50
|
+
perms = AgentLockPermissions(**data["agentlock"])
|
|
51
|
+
except ValidationError as e:
|
|
52
|
+
errors.extend(str(err) for err in e.errors())
|
|
53
|
+
else:
|
|
54
|
+
try:
|
|
55
|
+
perms = AgentLockPermissions(**data)
|
|
56
|
+
except ValidationError as e:
|
|
57
|
+
errors.extend(str(err) for err in e.errors())
|
|
58
|
+
|
|
59
|
+
if errors:
|
|
60
|
+
print(f"INVALID — {len(errors)} error(s):")
|
|
61
|
+
for err in errors:
|
|
62
|
+
print(f" {err}")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
print(f"VALID — {path.name}")
|
|
66
|
+
print(f" Version: {perms.version}")
|
|
67
|
+
print(f" Risk level: {perms.risk_level.value}")
|
|
68
|
+
print(f" Auth: {'required' if perms.requires_auth else 'not required'}")
|
|
69
|
+
print(f" Roles: {', '.join(perms.allowed_roles) or '(none — deny all)'}")
|
|
70
|
+
if perms.rate_limit:
|
|
71
|
+
print(f" Rate limit: {perms.rate_limit.max_calls}/{perms.rate_limit.window_seconds}s")
|
|
72
|
+
if perms.data_policy.prohibited_in_output:
|
|
73
|
+
print(f" Redacts: {', '.join(perms.data_policy.prohibited_in_output)}")
|
|
74
|
+
if perms.human_approval.required:
|
|
75
|
+
thr = perms.human_approval.threshold.value
|
|
76
|
+
ch = perms.human_approval.channel.value
|
|
77
|
+
print(f" Approval: {thr} via {ch}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _schema(args: argparse.Namespace) -> int:
|
|
82
|
+
"""Print the AgentLock JSON schema."""
|
|
83
|
+
from agentlock.schema import AgentLockPermissions
|
|
84
|
+
|
|
85
|
+
schema = AgentLockPermissions.model_json_schema()
|
|
86
|
+
print(json.dumps(schema, indent=2))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _init(args: argparse.Namespace) -> int:
|
|
91
|
+
"""Generate a starter tool definition with AgentLock permissions."""
|
|
92
|
+
template = {
|
|
93
|
+
"name": "my_tool",
|
|
94
|
+
"description": "Description of what this tool does",
|
|
95
|
+
"parameters": {
|
|
96
|
+
"param1": "string",
|
|
97
|
+
"param2": "integer",
|
|
98
|
+
},
|
|
99
|
+
"agentlock": {
|
|
100
|
+
"version": "1.0",
|
|
101
|
+
"risk_level": "medium",
|
|
102
|
+
"requires_auth": True,
|
|
103
|
+
"auth_methods": ["oauth2"],
|
|
104
|
+
"allowed_roles": ["user", "admin"],
|
|
105
|
+
"scope": {
|
|
106
|
+
"data_boundary": "authenticated_user_only",
|
|
107
|
+
"max_records": 10,
|
|
108
|
+
},
|
|
109
|
+
"rate_limit": {
|
|
110
|
+
"max_calls": 100,
|
|
111
|
+
"window_seconds": 3600,
|
|
112
|
+
},
|
|
113
|
+
"data_policy": {
|
|
114
|
+
"input_classification": "public",
|
|
115
|
+
"output_classification": "internal",
|
|
116
|
+
"prohibited_in_output": [],
|
|
117
|
+
"redaction": "none",
|
|
118
|
+
},
|
|
119
|
+
"audit": {
|
|
120
|
+
"log_level": "standard",
|
|
121
|
+
"include_parameters": True,
|
|
122
|
+
"retention_days": 90,
|
|
123
|
+
},
|
|
124
|
+
"human_approval": {
|
|
125
|
+
"required": False,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
output = Path(args.output) if args.output else Path("agentlock-tool.json")
|
|
130
|
+
output.write_text(json.dumps(template, indent=2) + "\n")
|
|
131
|
+
print(f"Created {output}")
|
|
132
|
+
print("Edit the file and run: agentlock validate " + str(output))
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _inspect(args: argparse.Namespace) -> int:
|
|
137
|
+
"""Display a human-readable summary of a tool's permissions."""
|
|
138
|
+
path = Path(args.file)
|
|
139
|
+
if not path.exists():
|
|
140
|
+
print(f"Error: file not found: {path}", file=sys.stderr)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
data = json.loads(path.read_text())
|
|
145
|
+
except json.JSONDecodeError as e:
|
|
146
|
+
print(f"Error: invalid JSON: {e}", file=sys.stderr)
|
|
147
|
+
return 1
|
|
148
|
+
|
|
149
|
+
from pydantic import ValidationError
|
|
150
|
+
|
|
151
|
+
from agentlock.schema import AgentLockPermissions
|
|
152
|
+
|
|
153
|
+
tool_name = data.get("name", path.stem)
|
|
154
|
+
|
|
155
|
+
if "agentlock" in data:
|
|
156
|
+
perms_data = data["agentlock"]
|
|
157
|
+
elif "name" in data:
|
|
158
|
+
perms_data = data.get("agentlock", {})
|
|
159
|
+
else:
|
|
160
|
+
perms_data = data
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
perms = AgentLockPermissions(**perms_data)
|
|
164
|
+
except ValidationError as e:
|
|
165
|
+
print(f"Error: invalid permissions: {e}", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
risk_colors = {
|
|
169
|
+
"none": "\033[92m", # green
|
|
170
|
+
"low": "\033[92m", # green
|
|
171
|
+
"medium": "\033[93m", # yellow
|
|
172
|
+
"high": "\033[91m", # red
|
|
173
|
+
"critical": "\033[91m\033[1m", # bold red
|
|
174
|
+
}
|
|
175
|
+
reset = "\033[0m"
|
|
176
|
+
rc = risk_colors.get(perms.risk_level.value, "")
|
|
177
|
+
|
|
178
|
+
print(f"\n Tool: {tool_name}")
|
|
179
|
+
print(f" {'─' * 50}")
|
|
180
|
+
print(f" Risk: {rc}{perms.risk_level.value.upper()}{reset}")
|
|
181
|
+
print(f" Auth required: {'Yes' if perms.requires_auth else 'No'}")
|
|
182
|
+
print(f" Auth methods: {', '.join(m.value for m in perms.auth_methods)}")
|
|
183
|
+
print(f" Allowed roles: {', '.join(perms.allowed_roles) or '(none — DENY ALL)'}")
|
|
184
|
+
print(f" Data boundary: {perms.scope.data_boundary.value}")
|
|
185
|
+
if perms.scope.max_records:
|
|
186
|
+
print(f" Max records: {perms.scope.max_records}")
|
|
187
|
+
if perms.rate_limit:
|
|
188
|
+
rl = perms.rate_limit
|
|
189
|
+
print(f" Rate limit: {rl.max_calls} calls / {rl.window_seconds}s")
|
|
190
|
+
print(f" Input class: {perms.data_policy.input_classification.value}")
|
|
191
|
+
print(f" Output class: {perms.data_policy.output_classification.value}")
|
|
192
|
+
if perms.data_policy.prohibited_in_output:
|
|
193
|
+
types = ", ".join(perms.data_policy.prohibited_in_output)
|
|
194
|
+
mode = perms.data_policy.redaction.value
|
|
195
|
+
print(f" Redacts: {types} ({mode})")
|
|
196
|
+
print(f" Session TTL: {perms.session.max_duration_seconds}s")
|
|
197
|
+
print(f" Audit level: {perms.audit.log_level.value}")
|
|
198
|
+
print(f" Retention: {perms.audit.retention_days} days")
|
|
199
|
+
if perms.human_approval.required:
|
|
200
|
+
thr = perms.human_approval.threshold.value
|
|
201
|
+
ch = perms.human_approval.channel.value
|
|
202
|
+
print(f" Approval: {thr} via {ch}")
|
|
203
|
+
else:
|
|
204
|
+
print(" Approval: not required")
|
|
205
|
+
print()
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _audit_query(args: argparse.Namespace) -> int:
|
|
210
|
+
"""Query the audit log."""
|
|
211
|
+
import time
|
|
212
|
+
|
|
213
|
+
from agentlock.audit import FileAuditBackend
|
|
214
|
+
|
|
215
|
+
path = args.log or str(Path.home() / ".agentlock" / "audit.jsonl")
|
|
216
|
+
backend = FileAuditBackend(path)
|
|
217
|
+
|
|
218
|
+
since = None
|
|
219
|
+
if args.since:
|
|
220
|
+
# Parse relative time like "1h", "24h", "7d"
|
|
221
|
+
val = args.since
|
|
222
|
+
if val.endswith("h"):
|
|
223
|
+
since = time.time() - int(val[:-1]) * 3600
|
|
224
|
+
elif val.endswith("d"):
|
|
225
|
+
since = time.time() - int(val[:-1]) * 86400
|
|
226
|
+
elif val.endswith("m"):
|
|
227
|
+
since = time.time() - int(val[:-1]) * 60
|
|
228
|
+
|
|
229
|
+
records = backend.query(
|
|
230
|
+
tool_name=args.tool,
|
|
231
|
+
user_id=args.user,
|
|
232
|
+
since=since,
|
|
233
|
+
limit=args.limit,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if not records:
|
|
237
|
+
print("No audit records found.")
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
for r in records:
|
|
241
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(r.timestamp))
|
|
242
|
+
action_color = "\033[92m" if r.action == "allowed" else "\033[91m"
|
|
243
|
+
reset = "\033[0m"
|
|
244
|
+
print(
|
|
245
|
+
f" {ts} {r.audit_id} {r.tool_name:20s} "
|
|
246
|
+
f"{action_color}{r.action:8s}{reset} "
|
|
247
|
+
f"{r.user_id or '-':15s} {r.role or '-':10s} "
|
|
248
|
+
f"{r.reason or ''}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
print(f"\n {len(records)} record(s)")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main(argv: list[str] | None = None) -> int:
|
|
256
|
+
"""CLI entry point."""
|
|
257
|
+
parser = argparse.ArgumentParser(
|
|
258
|
+
prog="agentlock",
|
|
259
|
+
description="AgentLock — Authorization framework for AI agent tool calls",
|
|
260
|
+
)
|
|
261
|
+
parser.add_argument(
|
|
262
|
+
"--version", action="version", version=f"agentlock {__version__}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
sub = parser.add_subparsers(dest="command")
|
|
266
|
+
|
|
267
|
+
# validate
|
|
268
|
+
p_validate = sub.add_parser("validate", help="Validate a tool definition")
|
|
269
|
+
p_validate.add_argument("file", help="Path to JSON file")
|
|
270
|
+
|
|
271
|
+
# schema
|
|
272
|
+
sub.add_parser("schema", help="Print the AgentLock JSON schema")
|
|
273
|
+
|
|
274
|
+
# init
|
|
275
|
+
p_init = sub.add_parser("init", help="Generate a starter tool definition")
|
|
276
|
+
p_init.add_argument("-o", "--output", help="Output path", default=None)
|
|
277
|
+
|
|
278
|
+
# inspect
|
|
279
|
+
p_inspect = sub.add_parser("inspect", help="Display permissions summary")
|
|
280
|
+
p_inspect.add_argument("file", help="Path to JSON file")
|
|
281
|
+
|
|
282
|
+
# audit
|
|
283
|
+
p_audit = sub.add_parser("audit", help="Query audit logs")
|
|
284
|
+
p_audit.add_argument("--tool", help="Filter by tool name")
|
|
285
|
+
p_audit.add_argument("--user", help="Filter by user ID")
|
|
286
|
+
p_audit.add_argument("--since", help="Time window (e.g. 1h, 24h, 7d)")
|
|
287
|
+
p_audit.add_argument("--limit", type=int, default=50, help="Max records")
|
|
288
|
+
p_audit.add_argument("--log", help="Audit log file path")
|
|
289
|
+
|
|
290
|
+
args = parser.parse_args(argv)
|
|
291
|
+
|
|
292
|
+
if args.command is None:
|
|
293
|
+
parser.print_help()
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
handlers = {
|
|
297
|
+
"validate": _validate,
|
|
298
|
+
"schema": _schema,
|
|
299
|
+
"init": _init,
|
|
300
|
+
"inspect": _inspect,
|
|
301
|
+
"audit": _audit_query,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return handlers[args.command](args)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
sys.exit(main())
|
agentlock/decorators.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Decorators for one-line tool protection.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from agentlock import agentlock, AuthorizationGate
|
|
6
|
+
|
|
7
|
+
gate = AuthorizationGate()
|
|
8
|
+
|
|
9
|
+
@agentlock(
|
|
10
|
+
gate,
|
|
11
|
+
risk_level="high",
|
|
12
|
+
requires_auth=True,
|
|
13
|
+
allowed_roles=["admin"],
|
|
14
|
+
rate_limit={"max_calls": 5, "window_seconds": 3600},
|
|
15
|
+
)
|
|
16
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
17
|
+
# ... send the email ...
|
|
18
|
+
return "sent"
|
|
19
|
+
|
|
20
|
+
# Calling the decorated function requires authorization context
|
|
21
|
+
result = send_email(to="bob@co.com", subject="Hi", body="Hello",
|
|
22
|
+
_user_id="alice", _role="admin")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import functools
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from typing import Any, TypeVar
|
|
31
|
+
|
|
32
|
+
from agentlock.gate import AuthorizationGate
|
|
33
|
+
from agentlock.schema import AgentLockPermissions
|
|
34
|
+
|
|
35
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
36
|
+
|
|
37
|
+
_RESERVED_KWARGS = {"_user_id", "_role", "_session_id", "_metadata"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def agentlock(
|
|
41
|
+
gate: AuthorizationGate,
|
|
42
|
+
*,
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
risk_level: str = "high",
|
|
45
|
+
requires_auth: bool = True,
|
|
46
|
+
allowed_roles: list[str] | None = None,
|
|
47
|
+
rate_limit: dict[str, int] | None = None,
|
|
48
|
+
data_policy: dict[str, Any] | None = None,
|
|
49
|
+
human_approval: dict[str, Any] | None = None,
|
|
50
|
+
scope: dict[str, Any] | None = None,
|
|
51
|
+
audit: dict[str, Any] | None = None,
|
|
52
|
+
session: dict[str, Any] | None = None,
|
|
53
|
+
permissions: AgentLockPermissions | dict[str, Any] | None = None,
|
|
54
|
+
) -> Callable[[F], F]:
|
|
55
|
+
"""Decorator that wraps a function with AgentLock authorization.
|
|
56
|
+
|
|
57
|
+
All AgentLock permission fields can be passed as keyword arguments.
|
|
58
|
+
Alternatively, pass a pre-built ``permissions`` object.
|
|
59
|
+
|
|
60
|
+
The decorated function accepts special ``_user_id``, ``_role``,
|
|
61
|
+
``_session_id``, and ``_metadata`` keyword arguments for auth context.
|
|
62
|
+
These are stripped before calling the underlying function.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
gate: The AuthorizationGate instance.
|
|
66
|
+
name: Tool name override. Defaults to function name.
|
|
67
|
+
risk_level: Risk classification.
|
|
68
|
+
requires_auth: Whether authentication is required.
|
|
69
|
+
allowed_roles: Roles permitted to invoke.
|
|
70
|
+
rate_limit: Rate limiting config dict.
|
|
71
|
+
data_policy: Data policy config dict.
|
|
72
|
+
human_approval: Human approval config dict.
|
|
73
|
+
scope: Scope config dict.
|
|
74
|
+
audit: Audit config dict.
|
|
75
|
+
session: Session config dict.
|
|
76
|
+
permissions: Pre-built permissions object (overrides other fields).
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Decorator that protects the function.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def decorator(func: F) -> F:
|
|
83
|
+
tool_name = name or func.__name__
|
|
84
|
+
|
|
85
|
+
# Build permissions
|
|
86
|
+
if permissions is not None:
|
|
87
|
+
if isinstance(permissions, dict):
|
|
88
|
+
perms = AgentLockPermissions(**permissions)
|
|
89
|
+
else:
|
|
90
|
+
perms = permissions
|
|
91
|
+
else:
|
|
92
|
+
perms_dict: dict[str, Any] = {
|
|
93
|
+
"risk_level": risk_level,
|
|
94
|
+
"requires_auth": requires_auth,
|
|
95
|
+
}
|
|
96
|
+
if allowed_roles is not None:
|
|
97
|
+
perms_dict["allowed_roles"] = allowed_roles
|
|
98
|
+
if rate_limit is not None:
|
|
99
|
+
perms_dict["rate_limit"] = rate_limit
|
|
100
|
+
if data_policy is not None:
|
|
101
|
+
perms_dict["data_policy"] = data_policy
|
|
102
|
+
if human_approval is not None:
|
|
103
|
+
perms_dict["human_approval"] = human_approval
|
|
104
|
+
if scope is not None:
|
|
105
|
+
perms_dict["scope"] = scope
|
|
106
|
+
if audit is not None:
|
|
107
|
+
perms_dict["audit"] = audit
|
|
108
|
+
if session is not None:
|
|
109
|
+
perms_dict["session"] = session
|
|
110
|
+
perms = AgentLockPermissions(**perms_dict)
|
|
111
|
+
|
|
112
|
+
# Register with gate
|
|
113
|
+
gate.register_tool(tool_name, perms)
|
|
114
|
+
|
|
115
|
+
if asyncio.iscoroutinefunction(func):
|
|
116
|
+
|
|
117
|
+
@functools.wraps(func)
|
|
118
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
119
|
+
user_id = kwargs.pop("_user_id", "")
|
|
120
|
+
role = kwargs.pop("_role", "")
|
|
121
|
+
kwargs.pop("_session_id", "")
|
|
122
|
+
meta = kwargs.pop("_metadata", None)
|
|
123
|
+
|
|
124
|
+
# Authorize through the gate
|
|
125
|
+
auth_result = gate.authorize(
|
|
126
|
+
tool_name,
|
|
127
|
+
user_id=user_id,
|
|
128
|
+
role=role,
|
|
129
|
+
parameters=kwargs,
|
|
130
|
+
metadata=meta,
|
|
131
|
+
)
|
|
132
|
+
auth_result.raise_if_denied()
|
|
133
|
+
assert auth_result.token is not None
|
|
134
|
+
|
|
135
|
+
# Execute: await the async function directly, then run
|
|
136
|
+
# through the gate's redaction/audit via execute()
|
|
137
|
+
# We wrap in a sync callable for gate.execute() compatibility
|
|
138
|
+
captured_result = await func(*args, **kwargs)
|
|
139
|
+
|
|
140
|
+
# Apply redaction if configured
|
|
141
|
+
redacted = gate.redact_output(tool_name, captured_result) \
|
|
142
|
+
if isinstance(captured_result, str) else None
|
|
143
|
+
if redacted and redacted.was_redacted:
|
|
144
|
+
# Consume token and return redacted output
|
|
145
|
+
gate.token_store.validate_and_consume(
|
|
146
|
+
auth_result.token.token_id, tool_name, kwargs,
|
|
147
|
+
)
|
|
148
|
+
return redacted.redacted
|
|
149
|
+
|
|
150
|
+
# Consume token for audit trail
|
|
151
|
+
gate.token_store.validate_and_consume(
|
|
152
|
+
auth_result.token.token_id, tool_name, kwargs,
|
|
153
|
+
)
|
|
154
|
+
return captured_result
|
|
155
|
+
|
|
156
|
+
async_wrapper._agentlock_tool_name = tool_name # type: ignore[attr-defined]
|
|
157
|
+
async_wrapper._agentlock_permissions = perms # type: ignore[attr-defined]
|
|
158
|
+
return async_wrapper # type: ignore[return-value]
|
|
159
|
+
|
|
160
|
+
else:
|
|
161
|
+
|
|
162
|
+
@functools.wraps(func)
|
|
163
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
164
|
+
user_id = kwargs.pop("_user_id", "")
|
|
165
|
+
role = kwargs.pop("_role", "")
|
|
166
|
+
kwargs.pop("_session_id", "")
|
|
167
|
+
meta = kwargs.pop("_metadata", None)
|
|
168
|
+
|
|
169
|
+
return gate.call(
|
|
170
|
+
tool_name,
|
|
171
|
+
lambda **p: func(*args, **p),
|
|
172
|
+
user_id=user_id,
|
|
173
|
+
role=role,
|
|
174
|
+
parameters=kwargs,
|
|
175
|
+
metadata=meta,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
sync_wrapper._agentlock_tool_name = tool_name # type: ignore[attr-defined]
|
|
179
|
+
sync_wrapper._agentlock_permissions = perms # type: ignore[attr-defined]
|
|
180
|
+
return sync_wrapper # type: ignore[return-value]
|
|
181
|
+
|
|
182
|
+
return decorator
|
agentlock/exceptions.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""AgentLock exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from agentlock.types import AuditId, DenialReason, RoleName
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentLockError(Exception):
|
|
12
|
+
"""Base exception for all AgentLock errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeniedError(AgentLockError):
|
|
16
|
+
"""Tool call was denied by the authorization gate.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
reason: Standardized denial code.
|
|
20
|
+
detail: Human-readable explanation.
|
|
21
|
+
required_role: Role needed for access, if applicable.
|
|
22
|
+
current_role: Caller's current role, if known.
|
|
23
|
+
suggestion: Actionable guidance for the caller.
|
|
24
|
+
audit_id: Audit record identifier for this denial.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
reason: DenialReason | str,
|
|
30
|
+
detail: str = "",
|
|
31
|
+
*,
|
|
32
|
+
required_role: RoleName | None = None,
|
|
33
|
+
current_role: RoleName | None = None,
|
|
34
|
+
suggestion: str = "",
|
|
35
|
+
audit_id: AuditId | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.reason = str(reason.value if hasattr(reason, "value") else reason)
|
|
38
|
+
self.detail = detail
|
|
39
|
+
self.required_role = required_role
|
|
40
|
+
self.current_role = current_role
|
|
41
|
+
self.suggestion = suggestion
|
|
42
|
+
self.audit_id = audit_id
|
|
43
|
+
super().__init__(self._format())
|
|
44
|
+
|
|
45
|
+
def _format(self) -> str:
|
|
46
|
+
parts = [f"denied: {self.reason}"]
|
|
47
|
+
if self.detail:
|
|
48
|
+
parts.append(self.detail)
|
|
49
|
+
return " — ".join(parts)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Serialize to the AgentLock denial response format."""
|
|
53
|
+
d: dict[str, Any] = {
|
|
54
|
+
"status": "denied",
|
|
55
|
+
"reason": self.reason,
|
|
56
|
+
}
|
|
57
|
+
if self.required_role:
|
|
58
|
+
d["required_role"] = self.required_role
|
|
59
|
+
if self.current_role:
|
|
60
|
+
d["current_role"] = self.current_role
|
|
61
|
+
if self.suggestion:
|
|
62
|
+
d["suggestion"] = self.suggestion
|
|
63
|
+
if self.audit_id:
|
|
64
|
+
d["audit_id"] = self.audit_id
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AuthenticationRequiredError(DeniedError):
|
|
69
|
+
"""Caller must authenticate before this tool can execute."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, auth_methods: list[str] | None = None, **kwargs: Any) -> None:
|
|
72
|
+
self.auth_methods = auth_methods or []
|
|
73
|
+
super().__init__(reason="not_authenticated", **kwargs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InsufficientRoleError(DeniedError):
|
|
77
|
+
"""Caller's role does not include required permissions."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
80
|
+
super().__init__(reason="insufficient_role", **kwargs)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ScopeViolationError(DeniedError):
|
|
84
|
+
"""Request exceeds the caller's data boundary or record limits."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
87
|
+
super().__init__(reason="scope_violation", **kwargs)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RateLimitedError(DeniedError):
|
|
91
|
+
"""Caller has exceeded rate limits for this tool."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
retry_after_seconds: int | None = None,
|
|
96
|
+
**kwargs: Any,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.retry_after_seconds = retry_after_seconds
|
|
99
|
+
super().__init__(reason="rate_limited", **kwargs)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SessionExpiredError(DeniedError):
|
|
103
|
+
"""Session has expired; re-authentication required."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
106
|
+
super().__init__(reason="session_expired", **kwargs)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ApprovalRequiredError(DeniedError):
|
|
110
|
+
"""Human approval is required before execution."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
channel: str = "push_notification",
|
|
115
|
+
**kwargs: Any,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.channel = channel
|
|
118
|
+
super().__init__(reason="approval_required", **kwargs)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TokenError(AgentLockError):
|
|
122
|
+
"""Base for token-related errors."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TokenInvalidError(TokenError):
|
|
126
|
+
"""Token is malformed or unrecognized."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TokenExpiredError(TokenError):
|
|
130
|
+
"""Token has passed its expiry time."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TokenReplayedError(TokenError):
|
|
134
|
+
"""Token has already been consumed (single-use enforcement)."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SchemaValidationError(AgentLockError):
|
|
138
|
+
"""AgentLock permissions block failed validation."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ConfigurationError(AgentLockError):
|
|
142
|
+
"""Library misconfiguration."""
|