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/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())
@@ -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
@@ -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."""