ai-agentcomply 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.
- agentcomply/__init__.py +59 -0
- agentcomply/__main__.py +64 -0
- agentcomply/decorators.py +58 -0
- agentcomply/engine.py +400 -0
- agentcomply/executor.py +57 -0
- agentcomply/guardrails.py +259 -0
- agentcomply/i18n.py +176 -0
- agentcomply/models.py +170 -0
- agentcomply/policy.py +272 -0
- agentcomply/schemas/policy-v1alpha1.schema.json +90 -0
- agentcomply/tokens.py +73 -0
- ai_agentcomply-0.2.0.dist-info/METADATA +144 -0
- ai_agentcomply-0.2.0.dist-info/RECORD +17 -0
- ai_agentcomply-0.2.0.dist-info/WHEEL +5 -0
- ai_agentcomply-0.2.0.dist-info/entry_points.txt +2 -0
- ai_agentcomply-0.2.0.dist-info/licenses/LICENSE +202 -0
- ai_agentcomply-0.2.0.dist-info/top_level.txt +1 -0
agentcomply/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""AgentComply: runtime authorization and guardrails SDK for AI agent tool calls."""
|
|
2
|
+
|
|
3
|
+
from .decorators import secure_tool
|
|
4
|
+
from .engine import AgentComply, AuthorizationDenied, AuditSink
|
|
5
|
+
from .executor import TrustedExecutor
|
|
6
|
+
from .guardrails import Guardrails, Signal, redact_output
|
|
7
|
+
from .i18n import SUPPORTED_LOCALES, normalize_locale
|
|
8
|
+
from .models import (
|
|
9
|
+
REASON_APPROVED,
|
|
10
|
+
REASON_DEFAULT_DENY,
|
|
11
|
+
REASON_EXPLICIT_DENY,
|
|
12
|
+
REASON_INJECTION_BLOCKED,
|
|
13
|
+
REASON_INVALID_REQUEST,
|
|
14
|
+
REASON_JAILBREAK_BLOCKED,
|
|
15
|
+
REASON_OFFENSIVE_BLOCKED,
|
|
16
|
+
REASON_PRINCIPAL_INVALID,
|
|
17
|
+
REASON_REQUIRE_APPROVAL,
|
|
18
|
+
REASON_RULE_ALLOW,
|
|
19
|
+
REASON_TASK_GRANT_DENIED,
|
|
20
|
+
Action,
|
|
21
|
+
AuthorizationRequest,
|
|
22
|
+
Decision,
|
|
23
|
+
Principal,
|
|
24
|
+
TaskGrant,
|
|
25
|
+
)
|
|
26
|
+
from .policy import PolicyError
|
|
27
|
+
|
|
28
|
+
__version__ = "0.2.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Action",
|
|
32
|
+
"AgentComply",
|
|
33
|
+
"AuditSink",
|
|
34
|
+
"AuthorizationDenied",
|
|
35
|
+
"AuthorizationRequest",
|
|
36
|
+
"Decision",
|
|
37
|
+
"Guardrails",
|
|
38
|
+
"Principal",
|
|
39
|
+
"PolicyError",
|
|
40
|
+
"REASON_APPROVED",
|
|
41
|
+
"REASON_DEFAULT_DENY",
|
|
42
|
+
"REASON_EXPLICIT_DENY",
|
|
43
|
+
"REASON_INJECTION_BLOCKED",
|
|
44
|
+
"REASON_INVALID_REQUEST",
|
|
45
|
+
"REASON_JAILBREAK_BLOCKED",
|
|
46
|
+
"REASON_OFFENSIVE_BLOCKED",
|
|
47
|
+
"REASON_PRINCIPAL_INVALID",
|
|
48
|
+
"REASON_REQUIRE_APPROVAL",
|
|
49
|
+
"REASON_RULE_ALLOW",
|
|
50
|
+
"REASON_TASK_GRANT_DENIED",
|
|
51
|
+
"Signal",
|
|
52
|
+
"SUPPORTED_LOCALES",
|
|
53
|
+
"TaskGrant",
|
|
54
|
+
"TrustedExecutor",
|
|
55
|
+
"normalize_locale",
|
|
56
|
+
"redact_output",
|
|
57
|
+
"secure_tool",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
agentcomply/__main__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Command-line tools.
|
|
2
|
+
|
|
3
|
+
python -m agentcomply [--lang en|zh-CN] check <policy.yaml>
|
|
4
|
+
python -m agentcomply [--lang en|zh-CN] screen --text "<text>"
|
|
5
|
+
|
|
6
|
+
``screen`` exits with status 3 when the text would be blocked for a write-type operation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .engine import AgentComply
|
|
15
|
+
from .guardrails import Guardrails, categories
|
|
16
|
+
from .i18n import SUPPORTED_LOCALES, normalize_locale, translate
|
|
17
|
+
from .policy import PolicyError
|
|
18
|
+
|
|
19
|
+
EXIT_BLOCKED = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="agentcomply", description="AgentComply policy and guardrail tools")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--lang",
|
|
26
|
+
default=None,
|
|
27
|
+
help=f"output language ({', '.join(SUPPORTED_LOCALES)}); default: $AGENTCOMPLY_LOCALE or en",
|
|
28
|
+
)
|
|
29
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
check = sub.add_parser("check", help="validate a policy file")
|
|
31
|
+
check.add_argument("policy", help="path to a YAML policy file")
|
|
32
|
+
screen = sub.add_parser("screen", help="screen text for jailbreak, injection and offensive signals")
|
|
33
|
+
screen.add_argument("--text", required=True, help="text to screen")
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
locale = normalize_locale(args.lang)
|
|
38
|
+
except ValueError as exc:
|
|
39
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
if args.command == "check":
|
|
43
|
+
try:
|
|
44
|
+
engine = AgentComply(args.policy, locale=locale)
|
|
45
|
+
except PolicyError as exc:
|
|
46
|
+
print(f"{translate('cli.invalid', locale)}: {exc}", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
print(f"{translate('cli.valid', locale)}: {engine.policy_id} ({engine.api_version})")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
guard = Guardrails(mode="enforce")
|
|
52
|
+
signals = guard.screen(args.text, "cli")
|
|
53
|
+
reason = guard.blocking_reason(signals, operation="write")
|
|
54
|
+
cats = ", ".join(categories(signals)) or "-"
|
|
55
|
+
print(f"{translate('cli.screen_signals', locale)}: {cats}")
|
|
56
|
+
if reason is None:
|
|
57
|
+
print(translate("cli.screen_clear", locale))
|
|
58
|
+
return 0
|
|
59
|
+
print(f"{translate('cli.screen_blocked', locale)}: {translate(f'reason.{reason}', locale, rule_id='')}")
|
|
60
|
+
return EXIT_BLOCKED
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``@secure_tool``: guard a Python function with authorization and the trusted executor.
|
|
2
|
+
|
|
3
|
+
Function keyword arguments become ``Action.parameters``, so they must be JSON-serializable.
|
|
4
|
+
Keyword names ``action``, ``authorization`` and ``fn`` are reserved by ``TrustedExecutor.execute``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import inspect
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .engine import AuthorizationDenied
|
|
15
|
+
from .executor import TrustedExecutor
|
|
16
|
+
from .models import Action, Principal, TaskGrant
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def secure_tool(
|
|
20
|
+
executor: TrustedExecutor,
|
|
21
|
+
*,
|
|
22
|
+
agent_id: str,
|
|
23
|
+
tool: str,
|
|
24
|
+
operation: str,
|
|
25
|
+
resource: str | Callable[..., str],
|
|
26
|
+
principal: Principal,
|
|
27
|
+
grant: TaskGrant | None = None,
|
|
28
|
+
untrusted: Iterable[str] = (),
|
|
29
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
30
|
+
untrusted_texts = tuple(untrusted)
|
|
31
|
+
|
|
32
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
33
|
+
if inspect.iscoroutinefunction(fn):
|
|
34
|
+
raise TypeError(executor.engine._t("execute.async_not_supported"))
|
|
35
|
+
|
|
36
|
+
@functools.wraps(fn)
|
|
37
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
38
|
+
resolved_resource = resource(*args, **kwargs) if callable(resource) else resource
|
|
39
|
+
action = Action(
|
|
40
|
+
agent_id=agent_id,
|
|
41
|
+
tool=tool,
|
|
42
|
+
operation=operation,
|
|
43
|
+
resource=resolved_resource,
|
|
44
|
+
parameters=dict(kwargs),
|
|
45
|
+
)
|
|
46
|
+
decision = executor.engine.authorize(
|
|
47
|
+
action, principal=principal, grant=grant, untrusted=untrusted_texts
|
|
48
|
+
)
|
|
49
|
+
if not decision.allowed or decision.execution_token is None:
|
|
50
|
+
raise AuthorizationDenied(
|
|
51
|
+
executor.engine._t("denied.execution", reason_code=decision.reason_code),
|
|
52
|
+
decision,
|
|
53
|
+
)
|
|
54
|
+
return executor.execute(action, decision.execution_token, fn, *args, **kwargs)
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
return decorator
|
agentcomply/engine.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""AgentComply engine: policy evaluation, guardrails, tokens and audit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import secrets
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .guardrails import (
|
|
15
|
+
OFFENSIVE,
|
|
16
|
+
Guardrails,
|
|
17
|
+
Signal,
|
|
18
|
+
categories,
|
|
19
|
+
redact_output,
|
|
20
|
+
tool_is_offensive,
|
|
21
|
+
)
|
|
22
|
+
from .i18n import normalize_locale, translate
|
|
23
|
+
from .models import (
|
|
24
|
+
EFFECT_ALLOW,
|
|
25
|
+
EFFECT_DENY,
|
|
26
|
+
EFFECT_REQUIRE_APPROVAL,
|
|
27
|
+
REASON_APPROVED,
|
|
28
|
+
REASON_DEFAULT_DENY,
|
|
29
|
+
REASON_EXPLICIT_DENY,
|
|
30
|
+
REASON_INVALID_REQUEST,
|
|
31
|
+
REASON_PRINCIPAL_INVALID,
|
|
32
|
+
REASON_REQUIRE_APPROVAL,
|
|
33
|
+
REASON_RULE_ALLOW,
|
|
34
|
+
REASON_TASK_GRANT_DENIED,
|
|
35
|
+
Action,
|
|
36
|
+
AuthorizationRequest,
|
|
37
|
+
Decision,
|
|
38
|
+
Principal,
|
|
39
|
+
TaskGrant,
|
|
40
|
+
canonical_json,
|
|
41
|
+
is_identifier,
|
|
42
|
+
)
|
|
43
|
+
from .policy import PolicyError, Policy, Rule, compile_resource_pattern, load_policy
|
|
44
|
+
from .tokens import TokenSigner
|
|
45
|
+
|
|
46
|
+
__all__ = ["AgentComply", "AuditSink", "AuthorizationDenied", "PolicyError"]
|
|
47
|
+
|
|
48
|
+
AuditSink = Callable[[dict[str, Any]], None]
|
|
49
|
+
EXECUTION_TTL_SECONDS = 60
|
|
50
|
+
MAX_APPROVAL_TTL_SECONDS = 3600
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthorizationDenied(RuntimeError):
|
|
54
|
+
"""Raised when an action is denied, a token is invalid, or the audit sink fails."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, decision: Decision | None = None) -> None:
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.decision = decision
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _utc_now() -> datetime:
|
|
62
|
+
return datetime.now(timezone.utc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _aware(value: datetime) -> datetime:
|
|
66
|
+
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _grant_covers(
|
|
70
|
+
grant: TaskGrant | None,
|
|
71
|
+
principal: Principal,
|
|
72
|
+
action: Action,
|
|
73
|
+
scopes: frozenset[str],
|
|
74
|
+
now: datetime,
|
|
75
|
+
) -> bool:
|
|
76
|
+
if grant is None or grant.subject != principal.subject:
|
|
77
|
+
return False
|
|
78
|
+
if _aware(grant.expires_at) <= now:
|
|
79
|
+
return False
|
|
80
|
+
if not scopes <= grant.scopes:
|
|
81
|
+
return False
|
|
82
|
+
for pattern in grant.resources:
|
|
83
|
+
try:
|
|
84
|
+
if compile_resource_pattern(pattern).fullmatch(action.resource):
|
|
85
|
+
return True
|
|
86
|
+
except ValueError:
|
|
87
|
+
continue
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AgentComply:
|
|
92
|
+
"""Local authorization engine with guardrails.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
policy: Path to a YAML policy (``version: 1`` or ``apiVersion: agentcomply.io/v1alpha1``),
|
|
96
|
+
or an already-parsed mapping.
|
|
97
|
+
audit_sink: Optional callable receiving one metadata-only event per decision.
|
|
98
|
+
If it raises, the decision is withheld and ``AuthorizationDenied`` is raised.
|
|
99
|
+
locale: ``"en"`` or ``"zh-CN"``. Defaults to ``AGENTCOMPLY_LOCALE``, then English.
|
|
100
|
+
signing_key: At least 32 bytes. Signs execution and approval tokens. Random if omitted.
|
|
101
|
+
clock: Returns the current aware datetime. Used for expiry checks (for tests).
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
PolicyError: If the policy is missing, unparsable, or invalid.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
policy: str | Path | Mapping[str, Any],
|
|
110
|
+
audit_sink: AuditSink | None = None,
|
|
111
|
+
locale: str | None = None,
|
|
112
|
+
signing_key: bytes | None = None,
|
|
113
|
+
clock: Callable[[], datetime] | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._locale = normalize_locale(locale)
|
|
116
|
+
self._audit_sink = audit_sink
|
|
117
|
+
self._clock = clock or _utc_now
|
|
118
|
+
self._policy: Policy = load_policy(policy, self._t)
|
|
119
|
+
self._guard = Guardrails(self._policy.detection.mode)
|
|
120
|
+
self._signer = TokenSigner(signing_key)
|
|
121
|
+
|
|
122
|
+
# ----------------------------------------------------------- properties
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def locale(self) -> str:
|
|
126
|
+
return self._locale
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def policy_id(self) -> str:
|
|
130
|
+
return self._policy.policy_id
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def policy_version(self) -> str:
|
|
134
|
+
return self._policy.version_hash
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def api_version(self) -> str:
|
|
138
|
+
return self._policy.api_version
|
|
139
|
+
|
|
140
|
+
def _t(self, key: str, **kwargs: object) -> str:
|
|
141
|
+
return translate(key, self._locale, **kwargs)
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------ screening
|
|
144
|
+
|
|
145
|
+
def screen(self, text: str, *, source: str = "untrusted", operation: str = "write") -> tuple[Signal, ...]:
|
|
146
|
+
"""Screen text and return signals. Also returns the blocking reason via ``blocking_reason``."""
|
|
147
|
+
return self._guard.screen(text, source)
|
|
148
|
+
|
|
149
|
+
def blocking_reason(self, signals: Iterable[Signal], operation: str = "write") -> str | None:
|
|
150
|
+
return self._guard.blocking_reason(signals, operation)
|
|
151
|
+
|
|
152
|
+
def guard_output(self, text: str) -> tuple[str, dict[str, int]]:
|
|
153
|
+
"""Redact secrets and personal identifiers from model output."""
|
|
154
|
+
return redact_output(text)
|
|
155
|
+
|
|
156
|
+
def _screen_action(self, action: Action, untrusted: tuple[str, ...]) -> tuple[Signal, ...]:
|
|
157
|
+
signals: list[Signal] = []
|
|
158
|
+
for text in untrusted:
|
|
159
|
+
signals.extend(self._guard.screen(text, "untrusted"))
|
|
160
|
+
signals.extend(self._guard.screen(canonical_json(dict(action.parameters)), "parameters"))
|
|
161
|
+
if tool_is_offensive(action.tool, action.operation):
|
|
162
|
+
signals.append(Signal(OFFENSIVE, "taxonomy.tool", 1.0, "action"))
|
|
163
|
+
return tuple(signals)
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------- evaluation
|
|
166
|
+
|
|
167
|
+
def _build(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
allowed: bool,
|
|
171
|
+
reason: str,
|
|
172
|
+
decision_id: str,
|
|
173
|
+
rule_id: str | None = None,
|
|
174
|
+
effect: str | None = None,
|
|
175
|
+
action_hash: str | None = None,
|
|
176
|
+
signals: Iterable[Signal] = (),
|
|
177
|
+
trace_id: str | None = None,
|
|
178
|
+
) -> Decision:
|
|
179
|
+
return Decision(
|
|
180
|
+
allowed=allowed,
|
|
181
|
+
reason_code=reason,
|
|
182
|
+
policy_id=self._policy.policy_id,
|
|
183
|
+
decision_id=decision_id,
|
|
184
|
+
rule_id=rule_id,
|
|
185
|
+
effect=effect,
|
|
186
|
+
action_hash=action_hash,
|
|
187
|
+
signal_categories=categories(signals),
|
|
188
|
+
trace_id=trace_id,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def _with_token(self, decision: Decision, action: Action, now: datetime) -> Decision:
|
|
192
|
+
payload = {
|
|
193
|
+
"kind": "execution",
|
|
194
|
+
"decision_id": decision.decision_id,
|
|
195
|
+
"action_hash": decision.action_hash,
|
|
196
|
+
"agent_id": action.agent_id,
|
|
197
|
+
"tool": action.tool,
|
|
198
|
+
"operation": action.operation,
|
|
199
|
+
"resource": action.resource,
|
|
200
|
+
"policy_id": self._policy.policy_id,
|
|
201
|
+
"exp": int(now.timestamp()) + EXECUTION_TTL_SECONDS,
|
|
202
|
+
"nonce": secrets.token_urlsafe(16),
|
|
203
|
+
}
|
|
204
|
+
return replace(decision, execution_token=self._signer.issue(payload))
|
|
205
|
+
|
|
206
|
+
def _evaluate(
|
|
207
|
+
self,
|
|
208
|
+
action: Action,
|
|
209
|
+
principal: Principal | None,
|
|
210
|
+
grant: TaskGrant | None,
|
|
211
|
+
approval: str | None,
|
|
212
|
+
untrusted: tuple[str, ...],
|
|
213
|
+
trace_id: str | None,
|
|
214
|
+
) -> Decision:
|
|
215
|
+
decision_id = str(uuid.uuid4())
|
|
216
|
+
now = self._clock()
|
|
217
|
+
bad = action.invalid_fields()
|
|
218
|
+
if principal is not None and not is_identifier(principal.subject):
|
|
219
|
+
bad.append("subject")
|
|
220
|
+
if bad:
|
|
221
|
+
return self._build(allowed=False, reason=REASON_INVALID_REQUEST, decision_id=decision_id, trace_id=trace_id)
|
|
222
|
+
if principal is None:
|
|
223
|
+
return self._build(allowed=False, reason=REASON_PRINCIPAL_INVALID, decision_id=decision_id, trace_id=trace_id)
|
|
224
|
+
if principal.expires_at is not None and _aware(principal.expires_at) <= now:
|
|
225
|
+
return self._build(allowed=False, reason=REASON_PRINCIPAL_INVALID, decision_id=decision_id, trace_id=trace_id)
|
|
226
|
+
|
|
227
|
+
ahash = action.action_hash()
|
|
228
|
+
signals = self._screen_action(action, untrusted)
|
|
229
|
+
blocked = self._guard.blocking_reason(signals, action.operation)
|
|
230
|
+
if blocked:
|
|
231
|
+
return self._build(
|
|
232
|
+
allowed=False, reason=blocked, decision_id=decision_id,
|
|
233
|
+
action_hash=ahash, signals=signals, trace_id=trace_id,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
matched: list[Rule] = [r for r in self._policy.rules if r.matches(action, principal.subject)]
|
|
237
|
+
|
|
238
|
+
for rule in matched:
|
|
239
|
+
if rule.effect == EFFECT_DENY:
|
|
240
|
+
return self._build(
|
|
241
|
+
allowed=False, reason=REASON_EXPLICIT_DENY, decision_id=decision_id, rule_id=rule.id,
|
|
242
|
+
effect=EFFECT_DENY, action_hash=ahash, signals=signals, trace_id=trace_id,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
approvals = [r for r in matched if r.effect == EFFECT_REQUIRE_APPROVAL]
|
|
246
|
+
if approvals:
|
|
247
|
+
rule = approvals[0]
|
|
248
|
+
if approval is not None and self._consume_approval(approval, ahash, now):
|
|
249
|
+
decision = self._build(
|
|
250
|
+
allowed=True, reason=REASON_APPROVED, decision_id=decision_id, rule_id=rule.id,
|
|
251
|
+
effect=EFFECT_REQUIRE_APPROVAL, action_hash=ahash, signals=signals, trace_id=trace_id,
|
|
252
|
+
)
|
|
253
|
+
return self._with_token(decision, action, now)
|
|
254
|
+
return self._build(
|
|
255
|
+
allowed=False, reason=REASON_REQUIRE_APPROVAL, decision_id=decision_id, rule_id=rule.id,
|
|
256
|
+
effect=EFFECT_REQUIRE_APPROVAL, action_hash=ahash, signals=signals, trace_id=trace_id,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
grant_rejected: Rule | None = None
|
|
260
|
+
for rule in matched:
|
|
261
|
+
if rule.effect != EFFECT_ALLOW:
|
|
262
|
+
continue
|
|
263
|
+
if rule.required_scopes and not _grant_covers(grant, principal, action, rule.required_scopes, now):
|
|
264
|
+
grant_rejected = rule
|
|
265
|
+
continue
|
|
266
|
+
decision = self._build(
|
|
267
|
+
allowed=True, reason=REASON_RULE_ALLOW, decision_id=decision_id, rule_id=rule.id,
|
|
268
|
+
effect=EFFECT_ALLOW, action_hash=ahash, signals=signals, trace_id=trace_id,
|
|
269
|
+
)
|
|
270
|
+
return self._with_token(decision, action, now)
|
|
271
|
+
|
|
272
|
+
if grant_rejected is not None:
|
|
273
|
+
return self._build(
|
|
274
|
+
allowed=False, reason=REASON_TASK_GRANT_DENIED, decision_id=decision_id,
|
|
275
|
+
rule_id=grant_rejected.id, effect=EFFECT_ALLOW, action_hash=ahash,
|
|
276
|
+
signals=signals, trace_id=trace_id,
|
|
277
|
+
)
|
|
278
|
+
return self._build(
|
|
279
|
+
allowed=False, reason=REASON_DEFAULT_DENY, decision_id=decision_id,
|
|
280
|
+
action_hash=ahash, signals=signals, trace_id=trace_id,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _normalize(
|
|
285
|
+
request: object, principal: Principal | None
|
|
286
|
+
) -> tuple[Action, Principal | None]:
|
|
287
|
+
if isinstance(request, AuthorizationRequest):
|
|
288
|
+
resolved = principal or Principal(subject=request.user_id)
|
|
289
|
+
return request.to_action(), resolved
|
|
290
|
+
if isinstance(request, Action):
|
|
291
|
+
return request, principal
|
|
292
|
+
raise TypeError("request must be an AuthorizationRequest or Action")
|
|
293
|
+
|
|
294
|
+
# ----------------------------------------------------------------- audit
|
|
295
|
+
|
|
296
|
+
def _emit_raw(self, event: dict[str, Any], decision: Decision | None = None) -> None:
|
|
297
|
+
if not self._policy.audit.enabled or self._audit_sink is None:
|
|
298
|
+
return
|
|
299
|
+
event = dict(event, policy_version=self._policy.version_hash)
|
|
300
|
+
try:
|
|
301
|
+
self._audit_sink(event)
|
|
302
|
+
except Exception as exc: # noqa: BLE001 - any sink failure must fail closed
|
|
303
|
+
raise AuthorizationDenied(self._t("denied.audit_failed"), decision) from exc
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------ public API
|
|
306
|
+
|
|
307
|
+
def authorize(
|
|
308
|
+
self,
|
|
309
|
+
request: AuthorizationRequest | Action,
|
|
310
|
+
*,
|
|
311
|
+
principal: Principal | None = None,
|
|
312
|
+
grant: TaskGrant | None = None,
|
|
313
|
+
approval: str | None = None,
|
|
314
|
+
untrusted: Iterable[str] = (),
|
|
315
|
+
trace_id: str | None = None,
|
|
316
|
+
) -> Decision:
|
|
317
|
+
"""Evaluate ``request`` and emit one audit event.
|
|
318
|
+
|
|
319
|
+
``untrusted`` takes text from RAG documents, tool responses or other
|
|
320
|
+
sources the model did not author. It is screened, never trusted.
|
|
321
|
+
Returns a ``Decision``. Callers must honor ``decision.allowed``, or use
|
|
322
|
+
``TrustedExecutor`` / ``execute()``.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
AuthorizationDenied: If the audit sink raises.
|
|
326
|
+
TypeError: If ``request`` has an unsupported type.
|
|
327
|
+
"""
|
|
328
|
+
action, resolved = self._normalize(request, principal)
|
|
329
|
+
texts = tuple(t for t in untrusted if isinstance(t, str))
|
|
330
|
+
decision = self._evaluate(action, resolved, grant, approval, texts, trace_id)
|
|
331
|
+
event = {"event": "authorization_decision", **decision.to_dict()}
|
|
332
|
+
self._emit_raw(event, decision)
|
|
333
|
+
return decision
|
|
334
|
+
|
|
335
|
+
def issue_approval(self, action: Action, ttl_seconds: int = 300) -> str:
|
|
336
|
+
"""Issue a single-use approval token bound to ``action``. Call after a human approves."""
|
|
337
|
+
if not isinstance(action, Action):
|
|
338
|
+
raise TypeError(self._t("request.not_a_request"))
|
|
339
|
+
if action.invalid_fields():
|
|
340
|
+
raise ValueError(self._t("request.invalid_action"))
|
|
341
|
+
if not 1 <= ttl_seconds <= MAX_APPROVAL_TTL_SECONDS:
|
|
342
|
+
raise ValueError(self._t("approval.ttl_out_of_range"))
|
|
343
|
+
now = int(self._clock().timestamp())
|
|
344
|
+
return self._signer.issue({
|
|
345
|
+
"kind": "approval",
|
|
346
|
+
"action_hash": action.action_hash(),
|
|
347
|
+
"exp": now + ttl_seconds,
|
|
348
|
+
"nonce": secrets.token_urlsafe(16),
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
def _consume_approval(self, token: str, action_hash: str | None, now: datetime) -> bool:
|
|
352
|
+
now_epoch = int(now.timestamp())
|
|
353
|
+
payload = self._signer.verify(token, kind="approval", now_epoch=now_epoch)
|
|
354
|
+
if payload is None or payload.get("action_hash") != action_hash:
|
|
355
|
+
return False
|
|
356
|
+
return self._signer.consume(payload["nonce"], payload["exp"], now_epoch)
|
|
357
|
+
|
|
358
|
+
def execute(
|
|
359
|
+
self,
|
|
360
|
+
request: AuthorizationRequest,
|
|
361
|
+
fn: Callable[..., Any],
|
|
362
|
+
*args: Any,
|
|
363
|
+
**kwargs: Any,
|
|
364
|
+
) -> Any:
|
|
365
|
+
"""Authorize (v1 request type) and call ``fn`` exactly once only if allowed.
|
|
366
|
+
|
|
367
|
+
``fn`` is never called on denial or audit failure. Only synchronous callables are supported.
|
|
368
|
+
For the full guarded flow (principal, grant, approval, token checks), use ``TrustedExecutor``.
|
|
369
|
+
"""
|
|
370
|
+
if inspect.iscoroutinefunction(fn):
|
|
371
|
+
raise TypeError(self._t("execute.async_not_supported"))
|
|
372
|
+
decision = self.authorize(request)
|
|
373
|
+
if not decision.allowed:
|
|
374
|
+
raise AuthorizationDenied(
|
|
375
|
+
self._t("denied.execution", reason_code=decision.reason_code), decision
|
|
376
|
+
)
|
|
377
|
+
return fn(*args, **kwargs)
|
|
378
|
+
|
|
379
|
+
def explain(self, decision: Decision) -> str:
|
|
380
|
+
"""Human-readable explanation in this instance's locale."""
|
|
381
|
+
return self._t(f"reason.{decision.reason_code}", rule_id=decision.rule_id or "")
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------- internal hooks for executors
|
|
384
|
+
|
|
385
|
+
def _verify_execution(self, token: object, action: Action) -> dict[str, Any] | None:
|
|
386
|
+
if action.invalid_fields():
|
|
387
|
+
return None
|
|
388
|
+
now_epoch = int(self._clock().timestamp())
|
|
389
|
+
payload = self._signer.verify(token, kind="execution", now_epoch=now_epoch)
|
|
390
|
+
if payload is None or payload.get("action_hash") != action.action_hash():
|
|
391
|
+
return None
|
|
392
|
+
if payload.get("policy_id") != self._policy.policy_id:
|
|
393
|
+
return None
|
|
394
|
+
return payload
|
|
395
|
+
|
|
396
|
+
def _consume_nonce(self, nonce: str, exp: int) -> bool:
|
|
397
|
+
return self._signer.consume(nonce, exp, int(self._clock().timestamp()))
|
|
398
|
+
|
|
399
|
+
def _emit_event(self, event: dict[str, Any]) -> None:
|
|
400
|
+
self._emit_raw(event)
|
agentcomply/executor.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Trusted tool executor (policy enforcement point).
|
|
2
|
+
|
|
3
|
+
The executor is the only path that calls protected tools. It re-verifies the
|
|
4
|
+
execution token, so a token cannot be replayed or moved to another action.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .engine import AgentComply, AuthorizationDenied
|
|
14
|
+
from .models import Action
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TrustedExecutor:
|
|
18
|
+
def __init__(self, engine: AgentComply) -> None:
|
|
19
|
+
self._engine = engine
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def engine(self) -> AgentComply:
|
|
23
|
+
return self._engine
|
|
24
|
+
|
|
25
|
+
def execute(
|
|
26
|
+
self,
|
|
27
|
+
action: Action,
|
|
28
|
+
authorization: str | None,
|
|
29
|
+
fn: Callable[..., Any],
|
|
30
|
+
*args: Any,
|
|
31
|
+
**kwargs: Any,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Verify the token for ``action``, record the event, then call ``fn`` once.
|
|
34
|
+
|
|
35
|
+
Fails closed: a missing, forged, expired, mismatched or replayed token
|
|
36
|
+
means ``fn`` is not called. An audit failure also means ``fn`` is not called.
|
|
37
|
+
"""
|
|
38
|
+
if not isinstance(action, Action):
|
|
39
|
+
raise TypeError(self._engine._t("request.not_a_request"))
|
|
40
|
+
if inspect.iscoroutinefunction(fn):
|
|
41
|
+
raise TypeError(self._engine._t("execute.async_not_supported"))
|
|
42
|
+
|
|
43
|
+
payload = self._engine._verify_execution(authorization, action)
|
|
44
|
+
if payload is None:
|
|
45
|
+
raise AuthorizationDenied(self._engine._t("denied.token_invalid"))
|
|
46
|
+
if not self._engine._consume_nonce(payload["nonce"], int(payload["exp"])):
|
|
47
|
+
raise AuthorizationDenied(self._engine._t("denied.token_replayed"))
|
|
48
|
+
|
|
49
|
+
self._engine._emit_event({
|
|
50
|
+
"event": "tool_execution",
|
|
51
|
+
"outcome": "about_to_execute",
|
|
52
|
+
"decision_id": payload["decision_id"],
|
|
53
|
+
"action_hash": payload["action_hash"],
|
|
54
|
+
"tool": action.tool,
|
|
55
|
+
"operation": action.operation,
|
|
56
|
+
})
|
|
57
|
+
return fn(*args, **kwargs)
|