agentguard-hitl 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.
agentguard/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """AgentGuard — stop dangerous AI agent actions before they execute."""
2
+
3
+ from .core import (
4
+ AgentGuard,
5
+ ApprovalRequest,
6
+ ActionDenied,
7
+ ResolveOutcome,
8
+ ApprovalStore,
9
+ InMemoryStore,
10
+ classify,
11
+ )
12
+
13
+ __all__ = [
14
+ "AgentGuard",
15
+ "ApprovalRequest",
16
+ "ActionDenied",
17
+ "ResolveOutcome",
18
+ "ApprovalStore",
19
+ "InMemoryStore",
20
+ "classify",
21
+ ]
22
+ __version__ = "0.2.0"
agentguard/core.py ADDED
@@ -0,0 +1,630 @@
1
+ """AgentGuard v0.2 — a permission gate for AI agent tool calls.
2
+
3
+ Primitives, stdlib only, works with any agent or framework:
4
+
5
+ AgentGuard().execute(name, params, executor) gate a tool dispatcher (sync)
6
+ await AgentGuard().aexecute(name, params, ...) gate a tool dispatcher (async)
7
+ @gate gate a single function
8
+ guard.pending() / guard.resolve(id, ok) request-based approval (web UI)
9
+ agentguard.log JSONL audit of gated actions
10
+
11
+ The gate is code, not a prompt. An agent cannot talk its way past it.
12
+ Design bias: over-gating is safe, under-gating is dangerous — so detection
13
+ errs toward asking, and every error path fails *closed* (blocks).
14
+
15
+ v0.2 adds concurrent multi-agent safety: every approval is a uniquely
16
+ identified ``ApprovalRequest`` parked in a per-request registry, so two agents
17
+ asking at once can never be mixed up — approving one never touches the other.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import functools
24
+ import inspect
25
+ import json
26
+ import os
27
+ import sys
28
+ import threading
29
+ import time
30
+ import uuid
31
+ from dataclasses import dataclass
32
+ from datetime import datetime, timedelta, timezone
33
+ from enum import Enum
34
+ from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple
35
+
36
+ # Audit log location — override with AGENTGUARD_LOG=/path/to/file
37
+ LOG_FILE = os.environ.get("AGENTGUARD_LOG", "agentguard.log")
38
+
39
+ # Dangerous-keyword -> human risk label. Scanned most-severe first; the first
40
+ # hit wins. Substring match on the normalized name, so `bulk_delete`,
41
+ # `deleteUser` and `DELETE-RECORD` all trip the same rule.
42
+ _RISK_RULES = (
43
+ (("delete", "drop", "wipe", "truncate", "purge", "destroy", "remove"),
44
+ "IRREVERSIBLE - cannot be undone"),
45
+ (("send", "email", "message", "notify", "publish", "post"),
46
+ "EXTERNAL SEND - leaves your system"),
47
+ (("execute", "run", "shell", "command", "exec", "eval", "spawn"),
48
+ "SYSTEM EXECUTION - runs arbitrary code"),
49
+ (("write", "overwrite", "update", "insert", "modify", "save"),
50
+ "DATA MUTATION - changes stored state"),
51
+ )
52
+
53
+ _LOG_LOCK = threading.Lock()
54
+
55
+
56
+ def _normalize(name: object) -> str:
57
+ """Lowercase and strip every non-alphanumeric char: delete_user -> deleteuser."""
58
+ return "".join(ch for ch in str(name).lower() if ch.isalnum())
59
+
60
+
61
+ def classify(name: object) -> Optional[str]:
62
+ """Return a risk label if the tool name looks dangerous, else None."""
63
+ flat = _normalize(name)
64
+ for keywords, label in _RISK_RULES:
65
+ if any(kw in flat for kw in keywords):
66
+ return label
67
+ return None
68
+
69
+
70
+ # --- Public value objects -----------------------------------------------------
71
+
72
+ @dataclass(frozen=True)
73
+ class ApprovalRequest:
74
+ """An immutable description of one dangerous action awaiting a decision.
75
+
76
+ Frozen so it can be shared across threads / handed to a web layer without
77
+ risk of mutation. ``request_id`` is the correlation key used everywhere:
78
+ in the log, in :meth:`AgentGuard.pending`, and in :meth:`AgentGuard.resolve`.
79
+ """
80
+
81
+ request_id: str
82
+ tool: str
83
+ params: Any
84
+ risk: str
85
+ reason: Optional[str] = None
86
+ agent_id: Optional[str] = None
87
+ session_id: Optional[str] = None
88
+ timestamp: str = ""
89
+ # Read-only metadata: ISO time this request auto-fails-closed, so a UI can
90
+ # render a countdown. Populated only in external mode when a timeout is set;
91
+ # None otherwise. Does NOT drive timeout behavior — it just surfaces it.
92
+ deadline: Optional[str] = None
93
+
94
+ def to_dict(self) -> Dict[str, Any]:
95
+ """Plain dict for JSON serialization (e.g. a /pending endpoint)."""
96
+ return {
97
+ "request_id": self.request_id,
98
+ "tool": self.tool,
99
+ "params": self.params,
100
+ "risk": self.risk,
101
+ "reason": self.reason,
102
+ "agent_id": self.agent_id,
103
+ "session_id": self.session_id,
104
+ "timestamp": self.timestamp,
105
+ "deadline": self.deadline,
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ActionDenied:
111
+ """Returned (never raised) when an action is not approved.
112
+
113
+ It is a *value*, not an exception, so agent frameworks that auto-retry on
114
+ raised errors will treat it as an ordinary tool result and NOT retry it
115
+ (Req. 7). It is falsy, so ``if guard.execute(...):`` reads naturally.
116
+ """
117
+
118
+ reason: str = "user_denied"
119
+ tool: Optional[str] = None
120
+ request_id: Optional[str] = None
121
+
122
+ def __bool__(self) -> bool:
123
+ return False
124
+
125
+ def __str__(self) -> str:
126
+ return f"Blocked by AgentGuard ({self.reason})"
127
+
128
+ def to_dict(self) -> Dict[str, Any]:
129
+ return {"denied": True, "reason": self.reason,
130
+ "tool": self.tool, "request_id": self.request_id}
131
+
132
+
133
+ class ApprovalState(Enum):
134
+ """Lifecycle of one parked request. Internal to the store."""
135
+
136
+ PENDING = "pending"
137
+ APPROVED = "approved"
138
+ DENIED = "denied"
139
+ EXPIRED = "expired"
140
+
141
+
142
+ class ResolveOutcome(Enum):
143
+ """What :meth:`AgentGuard.resolve` did.
144
+
145
+ Backward compatible with the old ``bool`` return: only ``RESOLVED`` is
146
+ truthy, so existing ``if guard.resolve(...):`` code still means
147
+ "I just resolved it". The others are falsy.
148
+ """
149
+
150
+ RESOLVED = "resolved" # THIS call decided it (first wins)
151
+ ALREADY_RESOLVED = "already_resolved" # decided already — safe idempotent no-op
152
+ UNKNOWN = "unknown" # no such request_id
153
+ EXPIRED = "expired" # deadline passed; already failed closed
154
+
155
+ def __bool__(self) -> bool:
156
+ return self is ResolveOutcome.RESOLVED
157
+
158
+
159
+ # --- Terminal rendering (degrades to ASCII on legacy code pages) --------------
160
+
161
+ def _supports_unicode() -> bool:
162
+ enc = getattr(sys.stdout, "encoding", None) or ""
163
+ try:
164
+ "⚠━".encode(enc)
165
+ return True
166
+ except (LookupError, UnicodeEncodeError, TypeError):
167
+ return False
168
+
169
+
170
+ _WARN = "⚠️ " if _supports_unicode() else "[!] "
171
+ _BAR = ("━" * 51) if _supports_unicode() else ("=" * 51)
172
+
173
+
174
+ def _show(params: Any) -> str:
175
+ try:
176
+ return json.dumps(params, default=str, ensure_ascii=False)
177
+ except Exception:
178
+ return repr(params)
179
+
180
+
181
+ class _TerminalConfirm:
182
+ """The default approval callback: a y/n terminal prompt.
183
+
184
+ Used when ``AgentGuard()`` is built with no confirm and no mode. It is an
185
+ ordinary confirm callback — not a special mode or code path — so it flows
186
+ through the same callback branch as any other approver. It serializes its
187
+ own prompts (its own lock) so concurrent agents don't interleave on the
188
+ shared console. No stdin (e.g. a headless process) -> returns False
189
+ (fail closed).
190
+ """
191
+
192
+ def __init__(self) -> None:
193
+ self._lock = threading.Lock()
194
+
195
+ def __call__(self, request: ApprovalRequest) -> bool:
196
+ with self._lock:
197
+ lines = [
198
+ "", _WARN + "AGENTGUARD: Action requires approval", _BAR,
199
+ "Tool : " + str(request.tool),
200
+ "Parameters: " + _show(request.params),
201
+ "Risk : " + str(request.risk),
202
+ ]
203
+ if request.reason:
204
+ lines.append("Reason : " + str(request.reason))
205
+ if request.agent_id:
206
+ lines.append("Agent : " + str(request.agent_id))
207
+ if request.session_id:
208
+ lines.append("Session : " + str(request.session_id))
209
+ lines.append("Request : " + request.request_id)
210
+ lines.append(_BAR)
211
+ sys.stdout.write("\n".join(lines) + "\n")
212
+ sys.stdout.flush()
213
+ try:
214
+ return input("Allow? (y/n): ").strip().lower() in ("y", "yes")
215
+ except (EOFError, KeyboardInterrupt, OSError):
216
+ sys.stdout.write("No interactive input available -> blocked (fail-closed).\n")
217
+ sys.stdout.flush()
218
+ return False
219
+
220
+
221
+ # The default approver used by AgentGuard() with no confirm and no mode.
222
+ _DEFAULT_CONFIRM = _TerminalConfirm()
223
+
224
+
225
+ def _log(tool: object, params: Any, decision: str, *,
226
+ reason: Optional[str] = None, agent_id: Optional[str] = None,
227
+ session_id: Optional[str] = None, request_id: Optional[str] = None,
228
+ actor: Optional[str] = None) -> None:
229
+ """Append one JSON line per gated action. Logging never raises."""
230
+ entry: Dict[str, Any] = {
231
+ "ts": datetime.now(timezone.utc).isoformat(),
232
+ "tool": str(tool),
233
+ "params": params,
234
+ "decision": decision,
235
+ }
236
+ for key, value in (("reason", reason), ("agent_id", agent_id),
237
+ ("session_id", session_id), ("request_id", request_id),
238
+ ("actor", actor)):
239
+ if value is not None:
240
+ entry[key] = value
241
+ try:
242
+ line = json.dumps(entry, default=str, ensure_ascii=False)
243
+ with _LOG_LOCK, open(LOG_FILE, "a", encoding="utf-8") as fh:
244
+ fh.write(line + "\n")
245
+ except Exception:
246
+ pass # an audit failure must never crash the agent
247
+
248
+
249
+ def _confirm_is_legacy(confirm: Callable) -> bool:
250
+ """True if ``confirm`` is the v0.1 ``confirm(tool, params)`` two-arg form.
251
+
252
+ A single-arg callable is the v0.2 ``confirm(request)`` form. If the
253
+ signature can't be introspected, assume the new (single-arg) form.
254
+ """
255
+ try:
256
+ sig = inspect.signature(confirm)
257
+ except (TypeError, ValueError):
258
+ return False
259
+ positional = [
260
+ p for p in sig.parameters.values()
261
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
262
+ inspect.Parameter.POSITIONAL_OR_KEYWORD)
263
+ ]
264
+ return len(positional) >= 2
265
+
266
+
267
+ class _Pending:
268
+ """Internal record for one in-flight request: the public request, the
269
+ in-process waiter that unblocks execute()/aexecute(), and the mutable
270
+ decision state. A custom :class:`ApprovalStore` stores and returns these
271
+ objects as-is — note ``event`` is an in-process primitive (see the
272
+ cross-process caveat on :class:`ApprovalStore`)."""
273
+
274
+ __slots__ = ("request", "event", "decision", "state", "actor",
275
+ "deadline_monotonic")
276
+
277
+ def __init__(self, request: ApprovalRequest,
278
+ deadline_monotonic: Optional[float] = None) -> None:
279
+ self.request = request
280
+ self.event = threading.Event() # set when a decision arrives
281
+ self.decision: Optional[bool] = None
282
+ self.state = ApprovalState.PENDING
283
+ self.actor: Optional[str] = None
284
+ self.deadline_monotonic = deadline_monotonic # None => never auto-expires
285
+
286
+
287
+ class ApprovalStore(Protocol):
288
+ """Pluggable persistence for parked approvals (Req. 5).
289
+
290
+ The default is :class:`InMemoryStore`. Implement this Protocol to back
291
+ approvals with your own store. ``decide`` MUST be an atomic compare-and-set
292
+ so two near-simultaneous resolutions of the same id can never both win.
293
+
294
+ CROSS-PROCESS CAVEAT: records carry an in-process ``threading.Event`` that
295
+ AgentGuard waits on. A store that spans processes must ALSO arrange to wake
296
+ the waiting process (polling, pub/sub, …); that wake-up is NOT provided
297
+ here. See the README — out of the box this works single-process only.
298
+ """
299
+
300
+ def put(self, record: _Pending) -> None: ...
301
+ def get(self, request_id: str) -> Optional[_Pending]: ...
302
+ def list_pending(self) -> List[ApprovalRequest]: ...
303
+ def decide(self, request_id: str, approved: bool,
304
+ actor: Optional[str]) -> ResolveOutcome: ...
305
+ def remove(self, request_id: str) -> None: ...
306
+
307
+
308
+ class InMemoryStore:
309
+ """Default store: a dict guarded by a lock — the same mechanism v0.2 used,
310
+ now behind the :class:`ApprovalStore` seam. Single process only.
311
+
312
+ Resolved/expired records are retained (not evicted) so duplicate
313
+ resolutions stay idempotent and :meth:`decide` can report
314
+ ALREADY_RESOLVED / EXPIRED accurately. Volume is bounded by the number of
315
+ gated actions; a custom store may add eviction.
316
+ """
317
+
318
+ def __init__(self) -> None:
319
+ self._lock = threading.Lock()
320
+ self._records: Dict[str, _Pending] = {}
321
+
322
+ def put(self, record: _Pending) -> None:
323
+ with self._lock:
324
+ self._records[record.request.request_id] = record
325
+
326
+ def get(self, request_id: str) -> Optional[_Pending]:
327
+ with self._lock:
328
+ return self._records.get(request_id)
329
+
330
+ def list_pending(self) -> List[ApprovalRequest]:
331
+ now = time.monotonic()
332
+ with self._lock:
333
+ return [r.request for r in self._records.values()
334
+ if r.state is ApprovalState.PENDING
335
+ and (r.deadline_monotonic is None
336
+ or now < r.deadline_monotonic)]
337
+
338
+ def decide(self, request_id: str, approved: bool,
339
+ actor: Optional[str]) -> ResolveOutcome:
340
+ """Atomic compare-and-set. First caller to find PENDING wins."""
341
+ with self._lock:
342
+ rec = self._records.get(request_id)
343
+ if rec is None:
344
+ return ResolveOutcome.UNKNOWN
345
+ if rec.state is not ApprovalState.PENDING:
346
+ return (ResolveOutcome.EXPIRED
347
+ if rec.state is ApprovalState.EXPIRED
348
+ else ResolveOutcome.ALREADY_RESOLVED)
349
+ if (rec.deadline_monotonic is not None
350
+ and time.monotonic() >= rec.deadline_monotonic):
351
+ rec.state = ApprovalState.EXPIRED # lazily mark; fail closed
352
+ return ResolveOutcome.EXPIRED
353
+ rec.decision = bool(approved)
354
+ rec.actor = actor
355
+ rec.state = (ApprovalState.APPROVED if approved
356
+ else ApprovalState.DENIED)
357
+ return ResolveOutcome.RESOLVED
358
+
359
+ def remove(self, request_id: str) -> None:
360
+ with self._lock:
361
+ self._records.pop(request_id, None)
362
+
363
+
364
+ class AgentGuard:
365
+ """Gate a tool dispatcher, safe under many concurrent agents.
366
+
367
+ guard = AgentGuard() # terminal y/n (default)
368
+ guard = AgentGuard(confirm=my_callback) # sync callback (Slack/web/etc.)
369
+ guard = AgentGuard(mode="external") # park requests; resolve() later
370
+
371
+ Then change one line in your loop:
372
+ result = guard.execute(tool_name, params, executor=run_tool,
373
+ agent_id="A", session_id="run-7")
374
+
375
+ Web UI / async approval: requests created in ``mode="external"`` are visible
376
+ via :meth:`pending` and answered via :meth:`resolve` — from any thread, in
377
+ any order. Each request waits on its own event, so answering one never
378
+ affects another. Pass ``on_request`` to be pushed each new request the
379
+ instant it is parked, and ``store`` to plug in your own persistence.
380
+
381
+ NOTE: external-mode approval works only when the code calling
382
+ :meth:`execute` / :meth:`aexecute` and the code calling :meth:`resolve` run
383
+ in the SAME process. Cross-process / distributed deployments need a custom
384
+ :class:`ApprovalStore` with its own cross-process wake-up (not provided).
385
+ """
386
+
387
+ def __init__(self, confirm: Optional[Callable] = None, *,
388
+ mode: Optional[str] = None,
389
+ timeout: Optional[float] = None,
390
+ on_request: Optional[Callable[[ApprovalRequest], Any]] = None,
391
+ store: Optional[ApprovalStore] = None,
392
+ dangerous_tools: Optional[List[str]] = None) -> None:
393
+ # Terminal approval is no longer a separate mode: with no confirm and no
394
+ # (or "terminal") mode, use the built-in terminal y/n confirm CALLBACK.
395
+ if confirm is None and mode in (None, "terminal"):
396
+ confirm = _DEFAULT_CONFIRM
397
+ mode = None
398
+ self._confirm = confirm
399
+ if confirm is not None:
400
+ self._mode = "callback"
401
+ self._confirm_legacy = _confirm_is_legacy(confirm)
402
+ else:
403
+ self._mode = mode
404
+ if self._mode != "external":
405
+ raise ValueError("mode must be 'external'")
406
+ self._confirm_legacy = False
407
+ self._timeout = timeout
408
+ self._on_request = on_request
409
+ self._store: ApprovalStore = store if store is not None else InMemoryStore()
410
+ # Explicit override: these tool names always gate, even if classify() says safe.
411
+ self._dangerous_tools = set(dangerous_tools or [])
412
+
413
+ # --- Web-UI / async-approval primitives (Req. 4 & 5) ----------------------
414
+
415
+ def pending(self) -> List[ApprovalRequest]:
416
+ """Snapshot of requests currently awaiting a decision.
417
+
418
+ A frontend polls this, renders the list, and calls :meth:`resolve`.
419
+ Returns frozen ``ApprovalRequest`` objects (safe to serialize).
420
+ """
421
+ return self._store.list_pending()
422
+
423
+ def gate(self, func: Callable) -> Callable:
424
+ """Decorator: force ``func`` to always require approval, through THIS
425
+ guard's configured mode (default-terminal / callback / external) — not
426
+ hardcoded terminal. Registers the function's name so it always gates,
427
+ then routes each call through :meth:`execute`. Returns ``ActionDenied``
428
+ on denial, like :meth:`execute`.
429
+
430
+ guard = AgentGuard(mode="external", on_request=push)
431
+ @guard.gate
432
+ def charge_card(amount): ... # parks a UI approval, not a y/n prompt
433
+ """
434
+ tool = getattr(func, "__name__", "function")
435
+ self._dangerous_tools.add(tool) # always gate this name
436
+ @functools.wraps(func)
437
+ def wrapper(*args, **kwargs):
438
+ params = {"args": list(args), "kwargs": kwargs}
439
+ return self.execute(tool, params,
440
+ executor=lambda _n, _p: func(*args, **kwargs))
441
+ return wrapper
442
+
443
+ def resolve(self, request_id: str, approved: bool, *,
444
+ actor: Optional[str] = None) -> ResolveOutcome:
445
+ """Answer one parked request by id, from ANY transport (HTTP, WS,
446
+ webhook). Thread-safe and idempotent: the FIRST call to a given id wins
447
+ and returns ``RESOLVED``; later calls return ``ALREADY_RESOLVED`` (or
448
+ ``EXPIRED`` / ``UNKNOWN``). A double-clicked button or a retried webhook
449
+ is therefore a safe no-op, never a double-execution.
450
+
451
+ Backward compatible — only ``RESOLVED`` is truthy, so existing
452
+ ``if guard.resolve(...):`` checks still work.
453
+
454
+ ``actor`` (optional) is recorded in the audit log for this decision.
455
+ """
456
+ outcome = self._store.decide(request_id, bool(approved), actor)
457
+ if outcome is ResolveOutcome.RESOLVED:
458
+ record = self._store.get(request_id)
459
+ if record is not None:
460
+ record.event.set() # wake the one parked execute()/aexecute()
461
+ return outcome
462
+
463
+ # --- Registry internals ---------------------------------------------------
464
+
465
+ def _fire_on_request(self, request: ApprovalRequest) -> None:
466
+ """Push a freshly-parked request to an external system. Best-effort:
467
+ any failure is logged and swallowed so a broken/​slow notifier can NEVER
468
+ wedge the gate — the request still sits in :meth:`pending` and still
469
+ fails closed on timeout exactly as without a notifier (Req. 2)."""
470
+ if self._on_request is None:
471
+ return
472
+ try:
473
+ self._on_request(request)
474
+ except Exception as exc:
475
+ _log(request.tool, request.params, "ON_REQUEST_ERROR",
476
+ agent_id=request.agent_id, session_id=request.session_id,
477
+ request_id=request.request_id, reason=repr(exc))
478
+
479
+ def _new_record(self, request: ApprovalRequest) -> _Pending:
480
+ """Create the in-flight record and park it in the store. The expiry
481
+ deadline is set only in external mode (terminal/callback resolve inline
482
+ and are unaffected, preserving their exact existing behavior)."""
483
+ deadline_monotonic = None
484
+ if self._mode == "external" and self._timeout is not None:
485
+ deadline_monotonic = time.monotonic() + self._timeout
486
+ record = _Pending(request, deadline_monotonic)
487
+ self._store.put(record)
488
+ return record
489
+
490
+ def _build_request(self, tool: str, params: Any, risk: str,
491
+ reason: Optional[str], agent_id: Optional[str],
492
+ session_id: Optional[str]) -> ApprovalRequest:
493
+ deadline = None
494
+ if self._mode == "external" and self._timeout is not None:
495
+ deadline = (datetime.now(timezone.utc)
496
+ + timedelta(seconds=self._timeout)).isoformat()
497
+ return ApprovalRequest(
498
+ request_id=str(uuid.uuid4()),
499
+ tool=str(tool),
500
+ params=params,
501
+ risk=risk,
502
+ reason=reason,
503
+ agent_id=agent_id,
504
+ session_id=session_id,
505
+ timestamp=datetime.now(timezone.utc).isoformat(),
506
+ deadline=deadline,
507
+ )
508
+
509
+ def _invoke_confirm(self, request: ApprovalRequest) -> Any:
510
+ if self._confirm_legacy:
511
+ return self._confirm(request.tool, request.params)
512
+ return self._confirm(request)
513
+
514
+ # --- Decision flow --------------------------------------------------------
515
+ # Every mode funnels into one model: park the request in the store, let a
516
+ # decision arrive via resolve(), wait on this request's own event, read its
517
+ # slot. Only the external branch changed for this release — terminal and
518
+ # callback still resolve inline exactly as before.
519
+
520
+ def _decide_sync(self, request: ApprovalRequest) -> Tuple[bool, str, Optional[str]]:
521
+ record = self._new_record(request)
522
+ try:
523
+ if self._confirm is not None: # callback (incl. default terminal confirm)
524
+ answer = False
525
+ try:
526
+ result = self._invoke_confirm(request)
527
+ if inspect.iscoroutine(result):
528
+ result.close() # async confirm needs aexecute()
529
+ else:
530
+ answer = bool(result)
531
+ except Exception:
532
+ answer = False # broken approver -> fail closed
533
+ self.resolve(request.request_id, answer)
534
+ else: # external mode
535
+ self._fire_on_request(request) # push; never blocks the gate
536
+
537
+ got = record.event.wait(self._timeout)
538
+ if not got:
539
+ return False, "approval_timeout", None
540
+ approved = record.decision is True
541
+ return approved, ("approved" if approved else "user_denied"), record.actor
542
+ finally:
543
+ # Terminal/callback resolve inline and need no late idempotent
544
+ # resolve(); external retains its record so resolve() stays
545
+ # idempotent and can report ALREADY_RESOLVED / EXPIRED.
546
+ if self._mode != "external":
547
+ self._store.remove(request.request_id)
548
+
549
+ async def _decide_async(self, request: ApprovalRequest) -> Tuple[bool, str, Optional[str]]:
550
+ record = self._new_record(request)
551
+ loop = asyncio.get_running_loop()
552
+ try:
553
+ if self._confirm is not None: # callback (incl. default terminal confirm)
554
+ answer = False
555
+ try:
556
+ result = self._invoke_confirm(request)
557
+ if inspect.iscoroutine(result):
558
+ result = await result
559
+ answer = bool(result)
560
+ except Exception:
561
+ answer = False
562
+ self.resolve(request.request_id, answer)
563
+ else: # external mode
564
+ self._fire_on_request(request)
565
+
566
+ # Wait without blocking the loop: park the threading.Event in the
567
+ # default executor. resolve() (called from anywhere) wakes it.
568
+ got = await loop.run_in_executor(
569
+ None, record.event.wait, self._timeout)
570
+ if not got:
571
+ return False, "approval_timeout", None
572
+ approved = record.decision is True
573
+ return approved, ("approved" if approved else "user_denied"), record.actor
574
+ finally:
575
+ if self._mode != "external":
576
+ self._store.remove(request.request_id)
577
+
578
+ # --- Public entry points --------------------------------------------------
579
+
580
+ def execute(self, tool_name: str, params: Any = None, executor: Callable = None,
581
+ *, reason: Optional[str] = None, agent_id: Optional[str] = None,
582
+ session_id: Optional[str] = None) -> Any:
583
+ if not callable(executor):
584
+ raise ValueError("guard.execute requires executor=<callable>")
585
+ risk = classify(tool_name)
586
+ if risk is None and tool_name in self._dangerous_tools:
587
+ risk = "MANUALLY FLAGGED - always requires approval"
588
+ if risk is None: # safe tool: silent, zero friction
589
+ return executor(tool_name, params)
590
+ request = self._build_request(
591
+ tool_name, params, risk, reason, agent_id, session_id)
592
+ approved, why, actor = self._decide_sync(request)
593
+ if approved:
594
+ _log(tool_name, params, "APPROVED", reason=reason, agent_id=agent_id,
595
+ session_id=session_id, request_id=request.request_id, actor=actor)
596
+ return executor(tool_name, params)
597
+ _log(tool_name, params, "BLOCKED", reason=why, agent_id=agent_id,
598
+ session_id=session_id, request_id=request.request_id, actor=actor)
599
+ return ActionDenied(reason=why, tool=str(tool_name),
600
+ request_id=request.request_id)
601
+
602
+ async def aexecute(self, tool_name: str, params: Any = None,
603
+ executor: Callable = None, *, reason: Optional[str] = None,
604
+ agent_id: Optional[str] = None,
605
+ session_id: Optional[str] = None) -> Any:
606
+ if not callable(executor):
607
+ raise ValueError("guard.aexecute requires executor=<callable>")
608
+ risk = classify(tool_name)
609
+ if risk is None and tool_name in self._dangerous_tools:
610
+ risk = "MANUALLY FLAGGED - always requires approval"
611
+ if risk is None:
612
+ return await _maybe_await(executor(tool_name, params))
613
+ request = self._build_request(
614
+ tool_name, params, risk, reason, agent_id, session_id)
615
+ approved, why, actor = await self._decide_async(request)
616
+ if approved:
617
+ _log(tool_name, params, "APPROVED", reason=reason, agent_id=agent_id,
618
+ session_id=session_id, request_id=request.request_id, actor=actor)
619
+ return await _maybe_await(executor(tool_name, params))
620
+ _log(tool_name, params, "BLOCKED", reason=why, agent_id=agent_id,
621
+ session_id=session_id, request_id=request.request_id, actor=actor)
622
+ return ActionDenied(reason=why, tool=str(tool_name),
623
+ request_id=request.request_id)
624
+
625
+
626
+ async def _maybe_await(value: Any) -> Any:
627
+ """Await ``value`` if it is a coroutine, else return it as-is."""
628
+ if inspect.iscoroutine(value):
629
+ return await value
630
+ return value
@@ -0,0 +1,364 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentguard-hitl
3
+ Version: 0.2.0
4
+ Summary: A permission gate for AI agent tool calls. Stops dangerous actions before they execute. Safe under many concurrent agents.
5
+ Project-URL: Homepage, https://github.com/agentguard/agentguard
6
+ License-Expression: MIT
7
+ Keywords: agents,ai,guardrails,llm,safety,tool-use
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # AgentGuard
12
+
13
+ AgentGuard is a stop-and-ask checkpoint for software that can take actions on its own. Some programs don't just return text — they can run real operations like deleting a file, sending an email, or charging a card by calling functions in your code. AgentGuard sits in front of those function calls: when a call looks dangerous, it pauses the program and asks a person to approve or deny it before anything runs. If nobody answers in time, the action is blocked. The check is plain code, so the program cannot skip it by "deciding" to.
14
+
15
+ No dependencies — pure Python standard library.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install agentguard-hitl
21
+ # or, from a clone of this repo:
22
+ pip install -e .
23
+ ```
24
+
25
+ ## Smallest working example
26
+
27
+ The main way to use AgentGuard is to wrap your tool calls in `guard.execute()`. It looks at the tool's name and, if the name looks dangerous, asks for approval before running the call.
28
+
29
+ ```python
30
+ from agentguard import AgentGuard
31
+
32
+ guard = AgentGuard() # approval happens in the terminal
33
+
34
+ def run_tool(name, params):
35
+ return f"ran {name} with {params}"
36
+
37
+ # "delete_file" contains "delete", so this asks before running:
38
+ result = guard.execute("delete_file", {"path": "notes.txt"}, executor=run_tool)
39
+ print(result)
40
+ ```
41
+
42
+ Run it and you get a prompt in your terminal:
43
+
44
+ ```
45
+ [!] AGENTGUARD: Action requires approval
46
+ ===================================================
47
+ Tool : delete_file
48
+ Parameters: {"path": "notes.txt"}
49
+ Risk : IRREVERSIBLE - cannot be undone
50
+ Request : 3f9c...
51
+ ===================================================
52
+ Allow? (y/n):
53
+ ```
54
+
55
+ Type `y` and `run_tool` runs. Type `n` — or run it where there's no keyboard — and the call is blocked and `result` is an `ActionDenied` value instead.
56
+
57
+ ## How it works
58
+
59
+ **What gets gated.** When you call `guard.execute(name, params, executor=run_tool)`, AgentGuard looks at the tool's *name*. If the name contains a word like `delete`, `wipe`, `drop`, `send`, `run`, or `write`, it asks for approval first. If the name looks safe, the call runs right away with no prompt. The check reads only the name — not what the function does or what arguments it gets.
60
+
61
+ **The two ways to gate, side by side.** `guard.execute()` is the main path: it runs the automatic name check. `guard.gate` is a manual override — a decorator bound to a guard that makes a function *always* require approval, regardless of its name. Both go through whichever approval mode the guard is configured with (terminal / callback / external). Use `execute()` for normal integration; use `guard.gate` to force approval on one specific function you already know is dangerous.
62
+
63
+ **Where the human is asked.** You pick one when you create the guard:
64
+
65
+ - **Terminal** (default): a `y/n` question in the console.
66
+ - **Callback**: you give a function that returns `True` or `False` (for example, it posts to Slack and waits for a click).
67
+ - **External**: the request is parked with an ID, and your own app approves it later by calling `resolve()`; `pending()` lists everything waiting. This is how you connect approvals to a web page or dashboard.
68
+
69
+ **The result.** If the call is approved, it runs and you get its normal return value. If it is denied — or if no one answers before the optional timeout — it does not run, and you get an `ActionDenied` value back (a value, not an error you have to catch).
70
+
71
+ ## The `guard.gate` decorator
72
+
73
+ Use `guard.gate` when you know a specific function is always dangerous regardless of its name. It is a decorator **bound to a guard**, so it forces approval through that guard's configured mode — terminal, callback, or external/UI.
74
+
75
+ ```python
76
+ guard = AgentGuard() # or mode="external", confirm=..., etc.
77
+
78
+ @guard.gate
79
+ def delete_file(path):
80
+ print(f"deleting {path}")
81
+
82
+ delete_file("notes.txt") # always asks for approval, through the guard's mode
83
+ ```
84
+
85
+ `guard.gate` registers the function's name so it always gates, then routes each call through `guard.execute()`. It gates every call regardless of the name and returns an `ActionDenied` value on denial (the same as `execute()`). Because it uses the guard's mode, on a `mode="external"` guard it shows up as a UI approval card, not a terminal prompt.
86
+
87
+ ## Function reference
88
+
89
+ Everything exported from the top-level `agentguard` package:
90
+
91
+ | Name | What it does | Returns |
92
+ |---|---|---|
93
+ | `AgentGuard` | The checkpoint object you put in front of tool calls. The main entry point. Its constructor takes `dangerous_tools=[...]` — a list of tool names that always require approval regardless of their name, through whichever mode you configured. | an `AgentGuard` instance |
94
+ | `classify` | Checks whether a tool name looks dangerous. | a risk-label string, or `None` if safe |
95
+ | `ApprovalRequest` | The details of one pending approval (tool, params, risk, ids, timestamp, deadline). Read-only. | an object; `.to_dict()` gives a plain dict |
96
+ | `ActionDenied` | The value returned when an action is blocked. It is "falsy" and is a value, not an exception. | an object; `.reason`, `.tool`, `.request_id` |
97
+ | `ResolveOutcome` | The result of answering a parked request: `RESOLVED`, `ALREADY_RESOLVED`, `UNKNOWN`, or `EXPIRED`. | an enum member |
98
+ | `ApprovalStore` | The interface for plugging in your own storage for pending approvals. | (a type to implement) |
99
+ | `InMemoryStore` | The default storage for pending approvals (kept in memory). | an `InMemoryStore` instance |
100
+
101
+ Methods on an `AgentGuard` instance:
102
+
103
+ | Method | What it does | Returns |
104
+ |---|---|---|
105
+ | `execute(tool_name, params, executor, *, reason=None, agent_id=None, session_id=None)` | Gates one tool call. Runs `executor(tool_name, params)` only if allowed. The main integration point. | the executor's result, or `ActionDenied` |
106
+ | `aexecute(...)` | Same as `execute`, for `async`/`await` code. | the executor's result, or `ActionDenied` |
107
+ | `resolve(request_id, approved, *, actor=None)` | Approves or denies a parked request (external mode). First answer wins. | a `ResolveOutcome` |
108
+ | `pending()` | Lists the approval requests currently waiting. | a list of `ApprovalRequest` |
109
+ | `gate(func)` | Decorator bound to this guard: forces `func` to always require approval, through this guard's mode. | the wrapped function |
110
+
111
+ ## Full integration example
112
+
113
+ This is a complete, runnable program. It shows the recommended pattern: keep your real tools as normal functions, put them behind one dispatcher, and route that dispatcher through `guard.execute()`.
114
+
115
+ ```python
116
+ from agentguard import AgentGuard, ActionDenied
117
+
118
+ # 1. Your real tool implementations. Each takes a params dict and does its work.
119
+ def read_customer(params):
120
+ return {"id": params["id"], "name": "Jane Doe", "plan": "pro"}
121
+
122
+ def update_email(params):
123
+ # pretend this writes to a database
124
+ return f"Email for #{params['id']} set to {params['email']}"
125
+
126
+ def delete_customer(params):
127
+ # pretend this deletes a row
128
+ return f"Customer #{params['id']} deleted"
129
+
130
+ # 2. One dispatcher mapping a tool name to its implementation.
131
+ TOOLS = {
132
+ "read_customer": read_customer,
133
+ "update_email": update_email,
134
+ "delete_customer": delete_customer,
135
+ }
136
+
137
+ def run_tool(name, params):
138
+ return TOOLS[name](params)
139
+
140
+ # 3. Create the guard. Default asks in the terminal.
141
+ guard = AgentGuard()
142
+ # Other options (pick ONE when you create the guard):
143
+ # guard = AgentGuard(confirm=lambda req: ask_slack_and_wait(req)) # returns True/False
144
+ # guard = AgentGuard(mode="external", on_request=push_to_ui, timeout=120) # web UI
145
+
146
+ # 4. Anywhere your code currently runs a tool, route it through the guard instead.
147
+ def handle_tool_call(name, params):
148
+ result = guard.execute(
149
+ name, params,
150
+ executor=run_tool, # the guard calls this only after approval
151
+ agent_id="support-bot", # optional: who is acting
152
+ session_id="ticket-4821", # optional: which run this belongs to
153
+ )
154
+ if isinstance(result, ActionDenied):
155
+ # Blocked. Return a plain message; do NOT automatically retry.
156
+ return f"Action blocked: {result.reason}"
157
+ return result
158
+
159
+ # 5. Use it.
160
+ print(handle_tool_call("read_customer", {"id": 7})) # safe name -> runs, no prompt
161
+ print(handle_tool_call("update_email", {"id": 7, "email": "a@b.com"})) # "email"/"update" -> asks first
162
+ print(handle_tool_call("delete_customer", {"id": 7})) # "delete" -> asks first
163
+ ```
164
+
165
+ Notes for adapting this:
166
+ - `executor` must be a function with the signature `executor(tool_name, params)`.
167
+ - A safe-named tool (`read_customer`) runs with no prompt. A dangerous-named one (`update_email`, `delete_customer`) asks first.
168
+ - To approve through a web UI instead of the terminal, create the guard with `mode="external"`, push each request to your UI from `on_request`, and call `guard.resolve(request_id, approved, actor=...)` from your Approve/Deny buttons. Always pass a finite `timeout`.
169
+ - `guard.gate` is not used here on purpose: `execute()` is the direct path. Reach for `@guard.gate` only to force approval on one specific function — it routes through this same guard, so it uses the same approval mode.
170
+
171
+ ## Use with Cursor, Claude Code, or any AI coding agent
172
+
173
+ Copy the block below and paste it into your own coding agent. It is a prompt to hand to a tool — not code to run directly.
174
+ I want to integrate AgentGuard into this project. AgentGuard is a library
175
+ that pauses an AI agent before it runs a dangerous or irreversible tool call
176
+ and asks a human to approve or deny it first. Install it with:
177
+ pip install agentguard-hitl
178
+
179
+ Before writing a single line of code, do the following discovery steps and
180
+ tell me what you find:
181
+
182
+ DISCOVERY (do this first, do not skip):
183
+ 1. Find every place in this codebase where an AI agent executes a tool or
184
+ function call — the dispatcher, the tool runner, wherever "the agent
185
+ picked a tool and now it runs." List every file and line.
186
+ 2. List every tool/function the agent can call. For each one, tell me:
187
+ does its name sound dangerous (delete, send, wipe, drop, write, run,
188
+ exec, update) or does it sound innocent but could be dangerous in
189
+ practice (charge_card, grant_admin, transfer_funds, deploy,
190
+ read_secrets, export, disable, reset, create_api_key, revoke)?
191
+ 3. Find where the existing UI is built — what framework (React, Vue, plain
192
+ HTML, Jinja, etc.), what components already exist for modals, cards,
193
+ dialogs, notifications, or alerts, and what the existing color scheme,
194
+ font, spacing, and button styles look like. I want the approval card to
195
+ look like it belongs in this UI, not like it was dropped in from outside.
196
+ 4. Find the web framework being used (Flask, FastAPI, Django, Express, etc.)
197
+ and where routes/endpoints are defined.
198
+ 5. Find how real-time or live updates currently work in this project —
199
+ WebSockets, Server-Sent Events, polling, a message queue, or nothing yet.
200
+ If nothing exists, identify the simplest option that fits this stack.
201
+ 6. Find if there is an existing authenticated user session — how is the
202
+ current user identified (user.id, session["user"], request.user, JWT,
203
+ etc.)? I need this for the approver identity record.
204
+
205
+ Do not proceed until you have listed findings for all six points above and
206
+ I have confirmed them.
207
+
208
+ IMPLEMENTATION (after discovery is confirmed):
209
+
210
+ Follow these exact steps in this exact order. Show me the diff for each
211
+ step before moving to the next. Do not batch them.
212
+
213
+ STEP 1 — Create the guard (one place, app startup):
214
+ - Import AgentGuard and ActionDenied at the top of the appropriate file
215
+ (wherever app-level objects like db connections or config are initialized).
216
+ - Define a push_to_ui(request) function that sends the approval request to
217
+ the frontend using whatever real-time mechanism exists (or the simplest
218
+ one you identified). It receives an ApprovalRequest object with these
219
+ fields: request_id, tool, params, risk, agent_id, session_id, reason,
220
+ timestamp, deadline. Send all of them — don't drop any.
221
+ - Create exactly one guard instance:
222
+ guard = AgentGuard(
223
+ mode="external",
224
+ on_request=push_to_ui,
225
+ timeout=120,
226
+ dangerous_tools=[LIST EVERY INNOCENTLY-NAMED DANGEROUS TOOL YOU FOUND]
227
+ )
228
+ - This instance must be importable by both the agent code and the route
229
+ handlers. Put it somewhere both can reach — a shared module, app state,
230
+ or dependency injection, whatever fits this project's existing pattern.
231
+
232
+ STEP 2 — Wrap the tool dispatcher (one line changed):
233
+ - Find the exact line(s) from discovery step 1 where tools execute.
234
+ - Change each one from:
235
+ result = run_tool(name, params)
236
+ to:
237
+ result = guard.execute(
238
+ name, params,
239
+ executor=run_tool,
240
+ agent_id=<how this agent is identified in this codebase>,
241
+ session_id=<current session or run id if one exists>
242
+ )
243
+ - Immediately after, handle denial:
244
+ if isinstance(result, ActionDenied):
245
+ <return or yield the denial message back to the agent in whatever
246
+ format this project uses for tool results — string, dict, JSON,
247
+ tool_result block, etc.>
248
+ <do NOT raise an exception, do NOT retry the call>
249
+ - If multiple agents share one dispatcher, this one change guards all of
250
+ them. If each agent has its own, make this change in each one.
251
+
252
+ STEP 3 — Add two backend routes:
253
+ Add these two endpoints using whatever routing pattern this project already
254
+ uses. Match the existing route style exactly (decorators, blueprints,
255
+ routers, controllers — whatever is already here):
256
+
257
+ Route 1 — list pending approvals (used by the UI to render the queue):
258
+ GET /agentguard/pending
259
+ Returns: guard.pending() serialized as JSON (each ApprovalRequest has
260
+ a .to_dict() method). Protect this route with whatever authentication
261
+ middleware already exists on sensitive routes in this project.
262
+
263
+ Route 2 — resolve an approval (called by Approve/Deny buttons):
264
+ POST /agentguard/resolve
265
+ Body: { request_id: string, approved: boolean }
266
+ Action: guard.resolve(request_id, approved, actor=<current user identity>)
267
+ Returns: { outcome: <ResolveOutcome value> }
268
+ On UNKNOWN or EXPIRED outcome, return an appropriate error response.
269
+ Protect this route identically to Route 1.
270
+
271
+ STEP 4 — Build the approval UI component:
272
+ - Build a single approval card component that matches the existing UI
273
+ exactly — same framework, same design system, same component library if
274
+ one exists. Do not introduce a new UI framework or new CSS library.
275
+ - The card must show ALL of these fields, labeled clearly:
276
+ Tool name (what the agent wants to run)
277
+ Parameters (what it's passing — shown as a readable key/value list)
278
+ Risk level (the risk label from AgentGuard)
279
+ Agent ID (which agent is asking)
280
+ Session ID (which run this belongs to)
281
+ Reason (if provided)
282
+ Countdown timer to deadline (live, counts down in seconds)
283
+ - Two buttons: Approve and Deny. On click, each calls the resolve route
284
+ with the correct request_id and approved=true/false.
285
+ - After clicking either button, disable both immediately to prevent
286
+ double-submission (the library handles it safely, but the UI should
287
+ reflect that the decision was made).
288
+ - When the deadline countdown hits zero, mark the card as expired and
289
+ disable both buttons.
290
+ - Wire the card to receive new requests via the same real-time mechanism
291
+ used in push_to_ui (step 1). The card should appear automatically when
292
+ a new request is parked — no manual refresh.
293
+ - Match the existing UI's: color scheme, font sizes, border radius, spacing,
294
+ button styles (primary/danger), modal or panel patterns, and any existing
295
+ loading/error states. If there's an existing modal or dialog component,
296
+ use it as the wrapper. If there are existing button components, use them.
297
+
298
+ STEP 5 — Verify end to end:
299
+ Run through this exact sequence manually and confirm each step works:
300
+ 1. Trigger an agent action that calls a tool with a dangerous-sounding name.
301
+ Confirm: card appears in UI, agent is paused.
302
+ 2. Click Approve. Confirm: agent continues, tool runs, result is returned.
303
+ 3. Trigger the same action again. Click Deny.
304
+ Confirm: ActionDenied is returned, agent receives the denial message,
305
+ does not retry.
306
+ 4. Trigger an action that calls a tool from the dangerous_tools list
307
+ (innocently named). Confirm: card appears (it was not silently allowed).
308
+ 5. Trigger an action that calls a safe tool (not dangerous-named, not in
309
+ dangerous_tools list). Confirm: no card appears, tool runs immediately.
310
+ 6. Click Approve twice on the same card (simulate double-click).
311
+ Confirm: second click returns ALREADY_RESOLVED, nothing bad happens.
312
+
313
+ THINGS YOU MUST NOT DO:
314
+ - Do not use @gate (the old top-level decorator — it no longer exists).
315
+ Use @guard.gate if you need to force-gate one specific function.
316
+ - Do not create more than one AgentGuard instance.
317
+ - Do not modify AgentGuard's internal code (core.py).
318
+ - Do not auto-retry a denied action.
319
+ - Do not set timeout=None — always use a finite number.
320
+ - Do not run the agent process and the web server in separate processes
321
+ unless you flag this to me explicitly, because cross-process approval
322
+ is not supported out of the box.
323
+ - Do not invent a new UI style — match what already exists.
324
+
325
+ After all five steps are complete and verified, give me:
326
+ 1. A list of every file changed and exactly what was changed in each.
327
+ 2. A list of any tools you found that are dangerous but NOT currently
328
+ covered by either the name classifier or the dangerous_tools list,
329
+ so I can decide whether to add them.
330
+ 3. Any architectural concern you noticed — specifically whether the agent
331
+ and web server run in the same process, and if not, what that means
332
+ for this integration.
333
+ ```
334
+
335
+ ## Known limitations
336
+
337
+ Read this before relying on AgentGuard. These are real and current.
338
+
339
+ **Critical / high — do not ignore:**
340
+
341
+ - **Ungated actions are also unlogged.** Only gated actions are written to the audit log. Combined with the point above, a dangerous-but-ordinarily-named action can run with no record at all.
342
+ - **Approved parameters are not frozen.** The values shown to the approver are held by reference. If they change between approval and execution, a different action can run than the one that was approved.
343
+ - **`confirm` plus `mode="external"` silently becomes callback mode.** If you pass both, the external/UI mode is ignored without warning. If your callback returns `True`, everything is auto-approved.
344
+ - **`timeout` defaults to waiting forever, and a wrong type crashes.** With no `timeout`, a parked external-mode request blocks indefinitely if nobody answers. A non-numeric `timeout` raises an error on the first gated call. Always pass a finite number.
345
+ - **Async use does not scale.** `aexecute()` parks each wait on a small shared thread pool. Many simultaneous approvals exhaust it and starve other async work, and a slow `on_request` callback blocks the event loop.
346
+ - **One process only.** External-mode approval works only when `execute()` and `resolve()` run in the same process. If you split them across processes or containers (agent in a worker, UI in a separate web server), the agent hangs until its timeout even after a human approves. There is no built-in cross-process support.
347
+
348
+ **Medium / low:**
349
+
350
+ - **Memory grows over time.** In external mode, resolved requests are kept in memory and never removed; a long-running server slowly accumulates them.
351
+ - **Parameters can leak secrets.** Tool parameters are written to the log and sent to the approval UI as-is. There is no redaction.
352
+ - **Name matching is fuzzy.** Substring matching over-flags some safe names (for example `evaluate_model`, `prune_old`). The audit log is a single local file with no rotation and no protection against multiple processes writing it at once.
353
+
354
+ **What you can honestly say it is:** a single-process, human-in-the-loop approval gate for AI tool calls, with terminal, callback, and same-process web-UI approval modes. It blocks when no one answers and when a timeout passes. Within one process, simultaneous approvals don't get mixed up and repeat approvals are safe.
355
+
356
+ **What it is not (yet):** not a security or authorization system, and not proven for production. It does not work across processes or containers, does not scale for heavy async use, does not keep a complete record of all activity, and does not catch dangerous actions by what they do — only by what they are named.
357
+
358
+ ## License
359
+
360
+ MIT.
361
+
362
+ ## Contributing
363
+
364
+ Issues and pull requests are welcome. If you change gating behavior, include a test that covers it (`python test_agentguard.py`, `python test_multiagent.py`, `python test_external_resolve.py`).
@@ -0,0 +1,5 @@
1
+ agentguard/__init__.py,sha256=5cXeysGIIMWUSKdqQX98v-3bh1d1cO1e4jMVCfT_yy4,402
2
+ agentguard/core.py,sha256=ysSKuDvBxhwING9TLx-ireaR6nWR_CYYATm7SYRwa7k,27321
3
+ agentguard_hitl-0.2.0.dist-info/METADATA,sha256=2hGJ9OXFCCauX0CLM7Xs9fe-1u56ayD9vnyCDBuNsxE,21126
4
+ agentguard_hitl-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ agentguard_hitl-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any