agent-guard-python 0.2.1__cp310-abi3-win_amd64.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.
- agent_guard/__init__.py +43 -0
- agent_guard/_agent_guard.pyd +0 -0
- agent_guard/adapters.py +410 -0
- agent_guard/langchain.py +160 -0
- agent_guard/openai.py +87 -0
- agent_guard_python-0.2.1.dist-info/METADATA +119 -0
- agent_guard_python-0.2.1.dist-info/RECORD +9 -0
- agent_guard_python-0.2.1.dist-info/WHEEL +4 -0
- agent_guard_python-0.2.1.dist-info/sboms/agent-guard-python.cyclonedx.json +7620 -0
agent_guard/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from ._agent_guard import (
|
|
2
|
+
Guard,
|
|
3
|
+
Decision,
|
|
4
|
+
ExecuteResult,
|
|
5
|
+
HandoffResult,
|
|
6
|
+
PolicyVerification,
|
|
7
|
+
RuntimeDecision,
|
|
8
|
+
RuntimeOutcome,
|
|
9
|
+
SandboxOutput,
|
|
10
|
+
GuardError,
|
|
11
|
+
__version__,
|
|
12
|
+
init_tracing,
|
|
13
|
+
)
|
|
14
|
+
from .adapters import (
|
|
15
|
+
AgentGuardAdapterError,
|
|
16
|
+
AgentGuardAskRequiredError,
|
|
17
|
+
AgentGuardDeniedError,
|
|
18
|
+
AgentGuardExecutionError,
|
|
19
|
+
AgentGuardSecurityError,
|
|
20
|
+
)
|
|
21
|
+
from .langchain import wrap_langchain_tool
|
|
22
|
+
from .openai import wrap_openai_tool
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Guard",
|
|
26
|
+
"Decision",
|
|
27
|
+
"ExecuteResult",
|
|
28
|
+
"HandoffResult",
|
|
29
|
+
"PolicyVerification",
|
|
30
|
+
"RuntimeDecision",
|
|
31
|
+
"RuntimeOutcome",
|
|
32
|
+
"SandboxOutput",
|
|
33
|
+
"GuardError",
|
|
34
|
+
"__version__",
|
|
35
|
+
"init_tracing",
|
|
36
|
+
"AgentGuardAdapterError",
|
|
37
|
+
"AgentGuardDeniedError",
|
|
38
|
+
"AgentGuardAskRequiredError",
|
|
39
|
+
"AgentGuardExecutionError",
|
|
40
|
+
"wrap_langchain_tool",
|
|
41
|
+
"wrap_openai_tool",
|
|
42
|
+
"AgentGuardSecurityError",
|
|
43
|
+
]
|
|
Binary file
|
agent_guard/adapters.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFAULT_MODE = "enforce"
|
|
8
|
+
DEFAULT_TRUST_LEVEL = "untrusted"
|
|
9
|
+
MAX_PAYLOAD_BYTES = 1024 * 1024
|
|
10
|
+
SHELL_TOOL_NAMES = {"bash", "shell", "terminal", "sh", "zsh", "cmd", "powershell", "pwsh"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentGuardAdapterError(Exception):
|
|
14
|
+
def __init__(self, message: str, *, decision: Any = None, status: Optional[str] = None,
|
|
15
|
+
policy_version: Optional[str] = None,
|
|
16
|
+
policy_verification_status: Optional[str] = None,
|
|
17
|
+
policy_verification_error: Optional[str] = None,
|
|
18
|
+
sandbox_type: Optional[str] = None, receipt: Optional[str] = None,
|
|
19
|
+
code: Optional[str] = None, matched_rule: Optional[str] = None,
|
|
20
|
+
ask_prompt: Optional[str] = None,
|
|
21
|
+
cause: Optional[BaseException] = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.decision = decision
|
|
24
|
+
self.status = status
|
|
25
|
+
self.policy_version = policy_version
|
|
26
|
+
self.policy_verification_status = policy_verification_status
|
|
27
|
+
self.policy_verification_error = policy_verification_error
|
|
28
|
+
self.sandbox_type = sandbox_type
|
|
29
|
+
self.receipt = receipt
|
|
30
|
+
self.code = code
|
|
31
|
+
self.matched_rule = matched_rule
|
|
32
|
+
self.ask_prompt = ask_prompt
|
|
33
|
+
self.cause = cause
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentGuardSecurityError(AgentGuardAdapterError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentGuardDeniedError(AgentGuardSecurityError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AgentGuardAskRequiredError(AgentGuardSecurityError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AgentGuardExecutionError(AgentGuardAdapterError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_mode(mode: str) -> str:
|
|
53
|
+
resolved = mode or DEFAULT_MODE
|
|
54
|
+
if resolved not in {"check", "enforce", "auto"}:
|
|
55
|
+
raise AgentGuardExecutionError(f"Unsupported adapter mode {resolved!r}", status="error")
|
|
56
|
+
return resolved
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_shell_tool_name(name: str) -> bool:
|
|
60
|
+
return str(name or "").lower() in SHELL_TOOL_NAMES
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_mode(tool_name: str, mode: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Resolve the requested adapter mode to one of "enforce", "check", or "run".
|
|
66
|
+
|
|
67
|
+
- "enforce" → always go through ``Guard.execute`` (sandboxed run).
|
|
68
|
+
- "check" → always go through ``Guard.check`` (policy-only, host runs original).
|
|
69
|
+
- "auto" → for shell tools, behave like "enforce"; for non-shell tools,
|
|
70
|
+
prefer ``Guard.run`` (the unified runtime API) when the binding
|
|
71
|
+
exposes it, falling back to "check" for older bindings.
|
|
72
|
+
|
|
73
|
+
The returned token "run" is a private contract between this helper and the
|
|
74
|
+
adapter dispatch path; it is only produced when the host's ``Guard`` actually
|
|
75
|
+
advertises a ``run`` method, so adapters can safely call ``guard.run`` after
|
|
76
|
+
seeing it.
|
|
77
|
+
"""
|
|
78
|
+
resolved = validate_mode(mode)
|
|
79
|
+
if resolved != "auto":
|
|
80
|
+
return resolved
|
|
81
|
+
if is_shell_tool_name(tool_name):
|
|
82
|
+
return "enforce"
|
|
83
|
+
# Non-shell auto: use the runtime API when available, else fall back to check.
|
|
84
|
+
return "run"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def has_runtime_api(guard: Any) -> bool:
|
|
88
|
+
"""True iff this Guard binding exposes the unified runtime API used by mode=auto."""
|
|
89
|
+
return callable(getattr(guard, "run", None))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def prepare_payload(tool_name: str, raw_input: Any) -> str:
|
|
93
|
+
shell_tool = is_shell_tool_name(tool_name)
|
|
94
|
+
if shell_tool:
|
|
95
|
+
if isinstance(raw_input, str):
|
|
96
|
+
payload = {"command": raw_input}
|
|
97
|
+
elif isinstance(raw_input, dict) and "command" in raw_input:
|
|
98
|
+
payload = raw_input
|
|
99
|
+
else:
|
|
100
|
+
payload = {"command": str(raw_input)}
|
|
101
|
+
elif isinstance(raw_input, (str, bytes, int, float, bool)):
|
|
102
|
+
payload = {"input": raw_input}
|
|
103
|
+
else:
|
|
104
|
+
payload = raw_input
|
|
105
|
+
|
|
106
|
+
payload_json = json.dumps(payload)
|
|
107
|
+
if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES:
|
|
108
|
+
raise ValueError("Tool payload too large (max 1MB)")
|
|
109
|
+
return payload_json
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Unified error-attribute extraction ───────────────────────────────────────
|
|
113
|
+
#
|
|
114
|
+
# All four error sites — Decision objects from check(), decisions embedded in
|
|
115
|
+
# ExecuteResult, RuntimeOutcome objects from run(), and synthetic policy-
|
|
116
|
+
# verification decisions — now flow through a single attribute extractor so
|
|
117
|
+
# the surfaced AgentGuardSecurityError / AgentGuardExecutionError instances
|
|
118
|
+
# carry identical fields regardless of which Guard API produced them.
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _decision_to_error_attrs(decision: Any) -> dict:
|
|
122
|
+
"""Pull the canonical error-shaping attributes off a Decision-like object.
|
|
123
|
+
|
|
124
|
+
Works for: Decision (from Guard.check), decisions embedded in ExecuteResult,
|
|
125
|
+
RuntimeOutcome variants from Guard.run, and the synthetic decision built in
|
|
126
|
+
``ensure_verified_policy``. Any of these may be missing fields; we always
|
|
127
|
+
produce the full attribute set with ``None`` for absent values.
|
|
128
|
+
|
|
129
|
+
For ``RuntimeOutcome`` shapes, ``code`` / ``matched_rule`` / ``ask_prompt``
|
|
130
|
+
live on the embedded ``decision`` child rather than the outcome itself, so
|
|
131
|
+
we look there first and fall back to the outer object for legacy shapes.
|
|
132
|
+
"""
|
|
133
|
+
inner = getattr(decision, "decision", None)
|
|
134
|
+
code_source = inner if inner is not None else decision
|
|
135
|
+
return {
|
|
136
|
+
"policy_version": getattr(decision, "policy_version", None),
|
|
137
|
+
"policy_verification_status": getattr(decision, "policy_verification_status", None),
|
|
138
|
+
"policy_verification_error": getattr(decision, "policy_verification_error", None),
|
|
139
|
+
"code": getattr(code_source, "code", None),
|
|
140
|
+
"matched_rule": getattr(code_source, "matched_rule", None),
|
|
141
|
+
"ask_prompt": getattr(code_source, "ask_prompt", None),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_security_error(decision: Any, *, fallback_message: Optional[str] = None) -> AgentGuardSecurityError:
|
|
146
|
+
outcome = getattr(decision, "outcome", "deny")
|
|
147
|
+
is_ask = outcome in ("ask_user", "ask_for_approval")
|
|
148
|
+
status = "ask_required" if is_ask else "denied"
|
|
149
|
+
message = (
|
|
150
|
+
getattr(decision, "ask_prompt", None)
|
|
151
|
+
or getattr(decision, "message", None)
|
|
152
|
+
or fallback_message
|
|
153
|
+
or ("agent-guard requires user approval before tool execution" if is_ask
|
|
154
|
+
else "agent-guard denied tool execution")
|
|
155
|
+
)
|
|
156
|
+
error_type = AgentGuardAskRequiredError if is_ask else AgentGuardDeniedError
|
|
157
|
+
return error_type(
|
|
158
|
+
message,
|
|
159
|
+
decision=decision,
|
|
160
|
+
status=status,
|
|
161
|
+
**_decision_to_error_attrs(decision),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ensure_verified_policy(decision: Any) -> None:
|
|
166
|
+
if getattr(decision, "policy_verification_status", None) != "invalid":
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
synthetic_decision = type("PolicyDecision", (), {
|
|
170
|
+
"outcome": "deny",
|
|
171
|
+
"message": getattr(decision, "policy_verification_error", None)
|
|
172
|
+
or "agent-guard refuses to continue with an invalid policy signature",
|
|
173
|
+
"code": "PolicyVerificationFailed",
|
|
174
|
+
"matched_rule": None,
|
|
175
|
+
"ask_prompt": None,
|
|
176
|
+
"policy_version": getattr(decision, "policy_version", None),
|
|
177
|
+
"policy_verification_status": getattr(decision, "policy_verification_status", None),
|
|
178
|
+
"policy_verification_error": getattr(decision, "policy_verification_error", None),
|
|
179
|
+
})()
|
|
180
|
+
raise build_security_error(synthetic_decision)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def handle_execute_result(result: Any, *, result_mapper: Optional[Callable[[Any, Any], Any]], original_input: Any) -> Any:
|
|
184
|
+
if result.status == "executed":
|
|
185
|
+
if callable(result_mapper):
|
|
186
|
+
return result_mapper(result, original_input)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
if result.decision is not None:
|
|
190
|
+
raise build_security_error(result.decision)
|
|
191
|
+
|
|
192
|
+
raise AgentGuardExecutionError(
|
|
193
|
+
"agent-guard returned an unknown execution status",
|
|
194
|
+
status=result.status,
|
|
195
|
+
policy_version=getattr(result, "policy_version", None),
|
|
196
|
+
policy_verification_status=getattr(result, "policy_verification_status", None),
|
|
197
|
+
policy_verification_error=getattr(result, "policy_verification_error", None),
|
|
198
|
+
sandbox_type=getattr(result, "sandbox_type", None),
|
|
199
|
+
receipt=getattr(result, "receipt", None),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Runtime-API dispatch (Guard.run) ─────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_handoff_outcome(outcome: Any) -> bool:
|
|
207
|
+
name = getattr(outcome, "outcome", None) or getattr(outcome, "status", None)
|
|
208
|
+
return name == "handoff"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _is_executed_outcome(outcome: Any) -> bool:
|
|
212
|
+
name = getattr(outcome, "outcome", None) or getattr(outcome, "status", None)
|
|
213
|
+
return name in ("executed", "execute")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_denied_outcome(outcome: Any) -> bool:
|
|
217
|
+
name = getattr(outcome, "outcome", None) or getattr(outcome, "status", None)
|
|
218
|
+
return name in ("denied", "deny")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_ask_outcome(outcome: Any) -> bool:
|
|
222
|
+
name = getattr(outcome, "outcome", None) or getattr(outcome, "status", None)
|
|
223
|
+
return name in ("ask_for_approval", "ask_user", "ask_required")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _build_handoff_result(guard: Any, *, exit_code: int, duration_ms: int,
|
|
227
|
+
stderr: Optional[str] = None) -> Any:
|
|
228
|
+
"""Construct a ``HandoffResult`` value the binding accepts.
|
|
229
|
+
|
|
230
|
+
Prefer the binding's exported ``HandoffResult`` class when present; fall
|
|
231
|
+
back to a duck-typed object so tests with mock guards remain decoupled
|
|
232
|
+
from the PyO3 layout.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
from . import HandoffResult as _HandoffResult # type: ignore
|
|
236
|
+
return _HandoffResult(exit_code=exit_code, duration_ms=duration_ms, stderr=stderr)
|
|
237
|
+
except Exception:
|
|
238
|
+
return type("HandoffResult", (), {
|
|
239
|
+
"exit_code": exit_code,
|
|
240
|
+
"duration_ms": duration_ms,
|
|
241
|
+
"stderr": stderr,
|
|
242
|
+
})()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def dispatch_via_run(
|
|
246
|
+
guard: Any,
|
|
247
|
+
*,
|
|
248
|
+
tool: str,
|
|
249
|
+
payload: str,
|
|
250
|
+
guard_options: dict,
|
|
251
|
+
handler: Callable[..., Any],
|
|
252
|
+
handler_args: tuple = (),
|
|
253
|
+
handler_kwargs: Optional[dict] = None,
|
|
254
|
+
) -> Any:
|
|
255
|
+
"""
|
|
256
|
+
Drive an ``auto``-mode (non-shell) tool through the unified ``Guard.run``
|
|
257
|
+
API and close the audit loop on Handoff.
|
|
258
|
+
|
|
259
|
+
Behaviour by ``RuntimeOutcome`` variant:
|
|
260
|
+
|
|
261
|
+
- ``Executed`` — return the sandbox output mapped through the standard
|
|
262
|
+
execute-result path. (Currently this only happens for shell-shaped
|
|
263
|
+
tools, but the branch is here for robustness.)
|
|
264
|
+
- ``Handoff`` — invoke ``handler`` to actually perform the action, time
|
|
265
|
+
it, and report the outcome back via ``Guard.report_handoff_result``
|
|
266
|
+
(exit_code 0 on clean return, 1 if the handler raised). The host
|
|
267
|
+
exception, if any, is re-raised AFTER the audit record is emitted so
|
|
268
|
+
the audit loop closes either way.
|
|
269
|
+
- ``Denied`` / ``AskForApproval`` — raise the appropriate
|
|
270
|
+
``AgentGuardSecurityError`` subclass via ``build_security_error``.
|
|
271
|
+
"""
|
|
272
|
+
handler_kwargs = handler_kwargs or {}
|
|
273
|
+
try:
|
|
274
|
+
outcome = guard.run(tool=tool, payload=payload, **guard_options)
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
raise AgentGuardExecutionError(
|
|
277
|
+
f"agent-guard run failed: {exc}",
|
|
278
|
+
status="error",
|
|
279
|
+
cause=exc,
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
if _is_handoff_outcome(outcome):
|
|
283
|
+
request_id = getattr(outcome, "request_id", "")
|
|
284
|
+
start = time.monotonic()
|
|
285
|
+
try:
|
|
286
|
+
result = handler(*handler_args, **handler_kwargs)
|
|
287
|
+
except BaseException as host_exc: # noqa: BLE001 — propagate after audit
|
|
288
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
289
|
+
handoff_result = _build_handoff_result(
|
|
290
|
+
guard,
|
|
291
|
+
exit_code=1,
|
|
292
|
+
duration_ms=duration_ms,
|
|
293
|
+
stderr=str(host_exc),
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
guard.report_handoff_result(request_id, handoff_result)
|
|
297
|
+
except Exception:
|
|
298
|
+
# Reporting must never mask the host failure; swallow audit
|
|
299
|
+
# errors and let the original exception propagate.
|
|
300
|
+
pass
|
|
301
|
+
raise
|
|
302
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
303
|
+
handoff_result = _build_handoff_result(
|
|
304
|
+
guard,
|
|
305
|
+
exit_code=0,
|
|
306
|
+
duration_ms=duration_ms,
|
|
307
|
+
stderr=None,
|
|
308
|
+
)
|
|
309
|
+
try:
|
|
310
|
+
guard.report_handoff_result(request_id, handoff_result)
|
|
311
|
+
except Exception:
|
|
312
|
+
# Same rationale as the failure branch: audit reporting must not
|
|
313
|
+
# silently corrupt a successful host execution.
|
|
314
|
+
pass
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
if _is_executed_outcome(outcome):
|
|
318
|
+
# Outcome carries an embedded sandbox output; return it raw — non-shell
|
|
319
|
+
# auto callers don't supply a result_mapper here, so the host receives
|
|
320
|
+
# the runtime outcome and can introspect output.stdout itself.
|
|
321
|
+
return outcome
|
|
322
|
+
|
|
323
|
+
if _is_denied_outcome(outcome) or _is_ask_outcome(outcome):
|
|
324
|
+
raise build_security_error(outcome)
|
|
325
|
+
|
|
326
|
+
raise AgentGuardExecutionError(
|
|
327
|
+
"agent-guard run returned an unknown outcome",
|
|
328
|
+
status=getattr(outcome, "outcome", None) or getattr(outcome, "status", "unknown"),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def run_check_async(guard: Any, *, tool: str, payload: str, guard_options: dict[str, Any]) -> Any:
|
|
333
|
+
return await asyncio.to_thread(guard.check, tool=tool, payload=payload, **guard_options)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def run_execute_async(guard: Any, *, tool: str, payload: str, guard_options: dict[str, Any]) -> Any:
|
|
337
|
+
return await asyncio.to_thread(guard.execute, tool=tool, payload=payload, **guard_options)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def dispatch_via_run_async(
|
|
341
|
+
guard: Any,
|
|
342
|
+
*,
|
|
343
|
+
tool: str,
|
|
344
|
+
payload: str,
|
|
345
|
+
guard_options: dict,
|
|
346
|
+
handler: Callable[..., Any],
|
|
347
|
+
handler_args: tuple = (),
|
|
348
|
+
handler_kwargs: Optional[dict] = None,
|
|
349
|
+
async_handler: Optional[Callable[..., Any]] = None,
|
|
350
|
+
) -> Any:
|
|
351
|
+
"""Async variant of :func:`dispatch_via_run`. ``async_handler``, when set,
|
|
352
|
+
is awaited on the Handoff path; otherwise ``handler`` is invoked on a
|
|
353
|
+
worker thread."""
|
|
354
|
+
handler_kwargs = handler_kwargs or {}
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
outcome = await asyncio.to_thread(
|
|
358
|
+
guard.run, tool=tool, payload=payload, **guard_options
|
|
359
|
+
)
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
raise AgentGuardExecutionError(
|
|
362
|
+
f"agent-guard run failed: {exc}",
|
|
363
|
+
status="error",
|
|
364
|
+
cause=exc,
|
|
365
|
+
) from exc
|
|
366
|
+
|
|
367
|
+
if _is_handoff_outcome(outcome):
|
|
368
|
+
request_id = getattr(outcome, "request_id", "")
|
|
369
|
+
start = time.monotonic()
|
|
370
|
+
try:
|
|
371
|
+
if async_handler is not None:
|
|
372
|
+
result = await async_handler(*handler_args, **handler_kwargs)
|
|
373
|
+
else:
|
|
374
|
+
result = await asyncio.to_thread(handler, *handler_args, **handler_kwargs)
|
|
375
|
+
except BaseException as host_exc: # noqa: BLE001
|
|
376
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
377
|
+
handoff_result = _build_handoff_result(
|
|
378
|
+
guard,
|
|
379
|
+
exit_code=1,
|
|
380
|
+
duration_ms=duration_ms,
|
|
381
|
+
stderr=str(host_exc),
|
|
382
|
+
)
|
|
383
|
+
try:
|
|
384
|
+
guard.report_handoff_result(request_id, handoff_result)
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
raise
|
|
388
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
389
|
+
handoff_result = _build_handoff_result(
|
|
390
|
+
guard,
|
|
391
|
+
exit_code=0,
|
|
392
|
+
duration_ms=duration_ms,
|
|
393
|
+
stderr=None,
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
guard.report_handoff_result(request_id, handoff_result)
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
if _is_executed_outcome(outcome):
|
|
402
|
+
return outcome
|
|
403
|
+
|
|
404
|
+
if _is_denied_outcome(outcome) or _is_ask_outcome(outcome):
|
|
405
|
+
raise build_security_error(outcome)
|
|
406
|
+
|
|
407
|
+
raise AgentGuardExecutionError(
|
|
408
|
+
"agent-guard run returned an unknown outcome",
|
|
409
|
+
status=getattr(outcome, "outcome", None) or getattr(outcome, "status", "unknown"),
|
|
410
|
+
)
|
agent_guard/langchain.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
from ._agent_guard import Guard
|
|
4
|
+
from .adapters import (
|
|
5
|
+
AgentGuardSecurityError,
|
|
6
|
+
build_security_error,
|
|
7
|
+
dispatch_via_run,
|
|
8
|
+
dispatch_via_run_async,
|
|
9
|
+
ensure_verified_policy,
|
|
10
|
+
handle_execute_result,
|
|
11
|
+
has_runtime_api,
|
|
12
|
+
prepare_payload,
|
|
13
|
+
resolve_mode,
|
|
14
|
+
run_check_async,
|
|
15
|
+
run_execute_async,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def wrap_langchain_tool(
|
|
19
|
+
guard: Guard,
|
|
20
|
+
tool: Any,
|
|
21
|
+
agent_id: Optional[str] = None,
|
|
22
|
+
actor: Optional[str] = None,
|
|
23
|
+
trust_level: str = "untrusted",
|
|
24
|
+
mode: str = "auto"
|
|
25
|
+
) -> Any:
|
|
26
|
+
"""
|
|
27
|
+
Wraps a LangChain tool with agent-guard security enforcement.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
guard: The initialized agent-guard.Guard instance.
|
|
31
|
+
tool: A LangChain tool instance (BaseTool).
|
|
32
|
+
agent_id: Optional ID for the agent.
|
|
33
|
+
actor: Optional ID for the human actor.
|
|
34
|
+
trust_level: Trust level for the execution ("untrusted", "trusted", "admin").
|
|
35
|
+
mode: One of:
|
|
36
|
+
|
|
37
|
+
- ``"enforce"`` — always sandbox the call via ``Guard.execute``.
|
|
38
|
+
- ``"check"`` — always go through ``Guard.check`` (policy-only;
|
|
39
|
+
the original tool runs in-process when allowed). Fail-closed on
|
|
40
|
+
invalid policy signatures.
|
|
41
|
+
- ``"auto"`` (default) — for shell-like tools, behave like
|
|
42
|
+
``enforce``. For non-shell tools, dispatch through the unified
|
|
43
|
+
``Guard.run`` runtime API when the binding exposes it. The
|
|
44
|
+
``RuntimeOutcome::Handoff`` variant means the host runs the
|
|
45
|
+
original tool itself and the adapter then closes the audit loop
|
|
46
|
+
via ``Guard.report_handoff_result``. When the binding does not
|
|
47
|
+
expose ``run`` (older builds), ``auto`` for non-shell tools
|
|
48
|
+
degrades to ``check`` semantics.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The same tool instance with guarded methods.
|
|
52
|
+
"""
|
|
53
|
+
if not hasattr(tool, "_run"):
|
|
54
|
+
raise ValueError("Provided object does not look like a LangChain BaseTool (missing _run).")
|
|
55
|
+
|
|
56
|
+
original_run = tool._run
|
|
57
|
+
original_arun = getattr(tool, "_arun", None)
|
|
58
|
+
resolved_mode = resolve_mode(tool.name, mode)
|
|
59
|
+
|
|
60
|
+
# Defensive fallback: if the binding doesn't expose Guard.run, downgrade
|
|
61
|
+
# the auto/non-shell path to "check" so this adapter still works against
|
|
62
|
+
# older PyO3 bindings.
|
|
63
|
+
if resolved_mode == "run" and not has_runtime_api(guard):
|
|
64
|
+
resolved_mode = "check"
|
|
65
|
+
|
|
66
|
+
def _raw_input(*args, **kwargs) -> Any:
|
|
67
|
+
if args and len(args) == 1 and not kwargs:
|
|
68
|
+
return args[0]
|
|
69
|
+
if kwargs and not args:
|
|
70
|
+
return kwargs
|
|
71
|
+
return {"args": args, "kwargs": kwargs}
|
|
72
|
+
|
|
73
|
+
guard_options = {
|
|
74
|
+
"agent_id": agent_id,
|
|
75
|
+
"actor": actor,
|
|
76
|
+
"trust_level": trust_level,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
def guarded_run(*args, **kwargs) -> Any:
|
|
80
|
+
raw_input = _raw_input(*args, **kwargs)
|
|
81
|
+
payload_str = prepare_payload(tool.name, raw_input)
|
|
82
|
+
|
|
83
|
+
if resolved_mode == "enforce":
|
|
84
|
+
result = guard.execute(tool=tool.name, payload=payload_str, **guard_options)
|
|
85
|
+
return handle_execute_result(
|
|
86
|
+
result,
|
|
87
|
+
result_mapper=lambda outcome, _original: outcome.output.stdout if outcome.output else "",
|
|
88
|
+
original_input=raw_input,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if resolved_mode == "run":
|
|
92
|
+
return dispatch_via_run(
|
|
93
|
+
guard,
|
|
94
|
+
tool=tool.name,
|
|
95
|
+
payload=payload_str,
|
|
96
|
+
guard_options=guard_options,
|
|
97
|
+
handler=original_run,
|
|
98
|
+
handler_args=args,
|
|
99
|
+
handler_kwargs=kwargs,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
decision = guard.check(tool=tool.name, payload=payload_str, **guard_options)
|
|
103
|
+
if decision.outcome != "allow":
|
|
104
|
+
raise build_security_error(decision)
|
|
105
|
+
ensure_verified_policy(decision)
|
|
106
|
+
return original_run(*args, **kwargs)
|
|
107
|
+
|
|
108
|
+
async def guarded_arun(*args, **kwargs) -> Any:
|
|
109
|
+
raw_input = _raw_input(*args, **kwargs)
|
|
110
|
+
payload_str = prepare_payload(tool.name, raw_input)
|
|
111
|
+
|
|
112
|
+
if resolved_mode == "enforce":
|
|
113
|
+
result = await run_execute_async(
|
|
114
|
+
guard,
|
|
115
|
+
tool=tool.name,
|
|
116
|
+
payload=payload_str,
|
|
117
|
+
guard_options=guard_options,
|
|
118
|
+
)
|
|
119
|
+
return handle_execute_result(
|
|
120
|
+
result,
|
|
121
|
+
result_mapper=lambda outcome, _original: outcome.output.stdout if outcome.output else "",
|
|
122
|
+
original_input=raw_input,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if resolved_mode == "run":
|
|
126
|
+
return await dispatch_via_run_async(
|
|
127
|
+
guard,
|
|
128
|
+
tool=tool.name,
|
|
129
|
+
payload=payload_str,
|
|
130
|
+
guard_options=guard_options,
|
|
131
|
+
handler=original_run,
|
|
132
|
+
handler_args=args,
|
|
133
|
+
handler_kwargs=kwargs,
|
|
134
|
+
async_handler=original_arun,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
decision = await run_check_async(
|
|
138
|
+
guard,
|
|
139
|
+
tool=tool.name,
|
|
140
|
+
payload=payload_str,
|
|
141
|
+
guard_options=guard_options,
|
|
142
|
+
)
|
|
143
|
+
if decision.outcome != "allow":
|
|
144
|
+
raise build_security_error(decision)
|
|
145
|
+
ensure_verified_policy(decision)
|
|
146
|
+
if original_arun:
|
|
147
|
+
return await original_arun(*args, **kwargs)
|
|
148
|
+
return await asyncio.to_thread(original_run, *args, **kwargs)
|
|
149
|
+
|
|
150
|
+
# Patch the tool internals.
|
|
151
|
+
try:
|
|
152
|
+
tool.__dict__["_run"] = guarded_run
|
|
153
|
+
if original_arun or hasattr(tool, "_arun"):
|
|
154
|
+
tool.__dict__["_arun"] = guarded_arun
|
|
155
|
+
except (AttributeError, TypeError):
|
|
156
|
+
tool._run = guarded_run
|
|
157
|
+
if original_arun or hasattr(tool, "_arun"):
|
|
158
|
+
tool._arun = guarded_arun
|
|
159
|
+
|
|
160
|
+
return tool
|