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.
@@ -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
+ ]
@@ -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)
@@ -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)