atbash-hermes-plugin 0.4.9.dev0__py3-none-any.whl → 0.4.10.dev1__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.
- atbash_hermes_plugin/__init__.py +281 -28
- {atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/METADATA +44 -43
- atbash_hermes_plugin-0.4.10.dev1.dist-info/RECORD +7 -0
- atbash_hermes_plugin-0.4.9.dev0.dist-info/RECORD +0 -7
- {atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/WHEEL +0 -0
- {atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/entry_points.txt +0 -0
- {atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/licenses/LICENSE +0 -0
- {atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/top_level.txt +0 -0
atbash_hermes_plugin/__init__.py
CHANGED
|
@@ -4,6 +4,7 @@ import atexit
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import inspect
|
|
7
8
|
import os
|
|
8
9
|
import re
|
|
9
10
|
import time
|
|
@@ -336,6 +337,134 @@ def _resolve_asset(token: str) -> str:
|
|
|
336
337
|
return "other"
|
|
337
338
|
|
|
338
339
|
|
|
340
|
+
_STATEMENT_SEP_RE = re.compile(r"[;&|<>$`\r\n]")
|
|
341
|
+
|
|
342
|
+
def _flag_values(command: str, name: str) -> list:
|
|
343
|
+
return [
|
|
344
|
+
value.strip("'\"")
|
|
345
|
+
for value in re.findall(rf"--{re.escape(name)}[= ]+(\S+)", command)
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
def _numeric_amount(value: Optional[str]) -> Optional[str]:
|
|
349
|
+
"""Return ``value`` only if it is a plain ASCII decimal, else ``None``.
|
|
350
|
+
|
|
351
|
+
``[0-9]`` rather than ``\\d``: in Python ``\\d`` matches every Unicode
|
|
352
|
+
decimal digit, so ``--amount ١٢٣`` passed the check and was forwarded
|
|
353
|
+
verbatim into the un-redacted ``resolved`` block. Callers must treat a
|
|
354
|
+
``None`` here as "unparsable", not as "no amount given" — see
|
|
355
|
+
``_canonicalize_financial``.
|
|
356
|
+
"""
|
|
357
|
+
if value is None or len(value) > 64:
|
|
358
|
+
return None
|
|
359
|
+
return value if re.fullmatch(r"(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)", value) else None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
_TRUE_VALUES = {"1", "true", "yes", "on"}
|
|
363
|
+
|
|
364
|
+
_FALSE_VALUES = {"0", "false", "no", "off"}
|
|
365
|
+
|
|
366
|
+
def _enforcement_enabled(value: Optional[str]) -> bool:
|
|
367
|
+
"""Parse ATBASH_ENFORCE_DECISION as opt-OUT, not opt-in.
|
|
368
|
+
|
|
369
|
+
This flag guards every fail-open branch in the guard, so it must take an
|
|
370
|
+
explicit, recognised falsey value to switch off. Read with the truthy
|
|
371
|
+
matching used for the other flags, an empty string — which is what
|
|
372
|
+
docker-compose passes for `ATBASH_ENFORCE_DECISION=${VAR}` when the host
|
|
373
|
+
variable is unset — or a typo like "y" or "strict" silently put the guard
|
|
374
|
+
in fail-open mode with nothing in the log to show for it.
|
|
375
|
+
"""
|
|
376
|
+
if value is None:
|
|
377
|
+
return True
|
|
378
|
+
normalized = value.strip().lower()
|
|
379
|
+
if normalized in _FALSE_VALUES:
|
|
380
|
+
return False
|
|
381
|
+
if normalized and normalized not in _TRUE_VALUES:
|
|
382
|
+
logger.error(
|
|
383
|
+
"ATBASH_ENFORCE_DECISION=%r is not a recognized value; "
|
|
384
|
+
"keeping enforcement on (fail-closed)",
|
|
385
|
+
value,
|
|
386
|
+
)
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _strict_reading(
|
|
391
|
+
command: str,
|
|
392
|
+
recipient_allowlist: set,
|
|
393
|
+
may_hide_other_actions: bool,
|
|
394
|
+
) -> Dict[str, Any]:
|
|
395
|
+
"""The most conservative concrete facts that survive an ambiguous command.
|
|
396
|
+
|
|
397
|
+
Replacing every fact with "ambiguous" makes asset-specific policy ("never
|
|
398
|
+
lifts ETH or ATBASH rules") unreachable for any command carrying a
|
|
399
|
+
separator. So report every asset named, the largest amount named (or
|
|
400
|
+
"unparsable"), and whether *every* recipient named is allowlisted.
|
|
401
|
+
|
|
402
|
+
Every field describes only the text that was parsed. When the command can
|
|
403
|
+
expand or chain into statements this never saw, `may_hide_other_actions` is
|
|
404
|
+
True and none of these facts bound what actually runs — which is why they
|
|
405
|
+
are named "…_named" and why nothing here may be read as permission.
|
|
406
|
+
"""
|
|
407
|
+
recipients = _flag_values(command, "to") + _flag_values(command, "recipient")
|
|
408
|
+
lowered_recipients = {r.lower() for r in recipients}
|
|
409
|
+
tokens = _flag_values(command, "token")
|
|
410
|
+
addresses = [
|
|
411
|
+
address
|
|
412
|
+
for address in _ERC20_RE.findall(command)
|
|
413
|
+
if address.lower() not in lowered_recipients
|
|
414
|
+
]
|
|
415
|
+
assets = sorted({_resolve_asset(t) for t in tokens + addresses}) or ["ETH"]
|
|
416
|
+
|
|
417
|
+
raw_amounts = _flag_values(command, "amount") + _flag_values(command, "max-usd")
|
|
418
|
+
parsed = [_numeric_amount(v) for v in raw_amounts]
|
|
419
|
+
if any(p is None for p in parsed):
|
|
420
|
+
max_amount: Optional[str] = "unparsable"
|
|
421
|
+
elif parsed:
|
|
422
|
+
max_amount = max(parsed, key=lambda v: float(v))
|
|
423
|
+
else:
|
|
424
|
+
max_amount = None
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
"assets_named": assets,
|
|
428
|
+
"max_amount_named": max_amount,
|
|
429
|
+
"recipients_named": len(recipients),
|
|
430
|
+
"all_named_recipients_allowlisted": bool(recipients)
|
|
431
|
+
and all(r.lower() in recipient_allowlist for r in recipients),
|
|
432
|
+
"may_hide_other_actions": may_hide_other_actions,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _assert_sdk_capabilities(
|
|
437
|
+
sdk: Any,
|
|
438
|
+
atbash_class: Any,
|
|
439
|
+
tool_call_input_class: Any,
|
|
440
|
+
) -> None:
|
|
441
|
+
guard_api_version = getattr(sdk, "GUARD_API_VERSION", 0)
|
|
442
|
+
if not isinstance(guard_api_version, int) or guard_api_version < 1:
|
|
443
|
+
raise RuntimeError(
|
|
444
|
+
"atbash-sdk does not expose hardened guard API version 1; "
|
|
445
|
+
"install atbash-sdk==0.2.0"
|
|
446
|
+
)
|
|
447
|
+
if not callable(getattr(atbash_class, "from_config", None)):
|
|
448
|
+
raise RuntimeError("atbash-sdk is missing Atbash.from_config")
|
|
449
|
+
if not callable(getattr(atbash_class, "audit_tool_call", None)):
|
|
450
|
+
raise RuntimeError("atbash-sdk is missing Atbash.audit_tool_call")
|
|
451
|
+
try:
|
|
452
|
+
from_config_parameters = inspect.signature(
|
|
453
|
+
atbash_class.from_config
|
|
454
|
+
).parameters
|
|
455
|
+
parameters = inspect.signature(tool_call_input_class).parameters
|
|
456
|
+
except (TypeError, ValueError) as error:
|
|
457
|
+
raise RuntimeError("cannot inspect atbash-sdk guard APIs") from error
|
|
458
|
+
for required_parameter in ("judge", "org_name", "fail_closed"):
|
|
459
|
+
if required_parameter not in from_config_parameters:
|
|
460
|
+
raise RuntimeError(
|
|
461
|
+
"atbash-sdk Atbash.from_config is missing "
|
|
462
|
+
f"{required_parameter} support"
|
|
463
|
+
)
|
|
464
|
+
if "resolved" not in parameters:
|
|
465
|
+
raise RuntimeError("atbash-sdk ToolCallInput is missing resolved support")
|
|
466
|
+
|
|
467
|
+
|
|
339
468
|
def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[Dict[str, Any]]:
|
|
340
469
|
if not command:
|
|
341
470
|
return None
|
|
@@ -345,18 +474,90 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
|
|
|
345
474
|
return None
|
|
346
475
|
|
|
347
476
|
def _flag(name: str) -> Optional[str]:
|
|
348
|
-
|
|
349
|
-
return
|
|
477
|
+
values = _flag_values(command, name)
|
|
478
|
+
return values[0] if values else None
|
|
350
479
|
|
|
480
|
+
op = (
|
|
481
|
+
"swap" if re.search(r"\bswap\b", command, re.IGNORECASE)
|
|
482
|
+
else "approve" if re.search(r"\b(approve|erc20)\b", command, re.IGNORECASE)
|
|
483
|
+
else "transfer"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# The judge rules on this block rather than on the raw command — the SDK
|
|
487
|
+
# redacts every 0x address out of args before it is sent — so reading only
|
|
488
|
+
# the first occurrence of a flag lets a prompt-injected command assert an
|
|
489
|
+
# allowlisted 1-unit transfer while what actually executes is a second one.
|
|
490
|
+
# CLI parsers are last-flag-wins and a shell runs every `;`-separated
|
|
491
|
+
# statement. When the text can mean more than one thing, say so instead of
|
|
492
|
+
# picking a reading and asserting it.
|
|
493
|
+
repeated_recipient = len(_flag_values(command, "to")) + len(
|
|
494
|
+
_flag_values(command, "recipient")
|
|
495
|
+
) > 1
|
|
496
|
+
repeated_flag = any(
|
|
497
|
+
len(_flag_values(command, name)) > 1
|
|
498
|
+
for name in ("token", "amount", "max-usd")
|
|
499
|
+
)
|
|
500
|
+
multi_statement = bool(_STATEMENT_SEP_RE.search(command))
|
|
501
|
+
|
|
502
|
+
# "absent" and "present but unparsable" must not collapse to the same
|
|
503
|
+
# signal. `--amount 1e9` parses as no plain decimal, and reporting it as
|
|
504
|
+
# amount=None next to recipient_status="allowlisted" hands the judge
|
|
505
|
+
# "allowlisted recipient, no amount stated" for a command that names a very
|
|
506
|
+
# large one — which is exactly what an "allow allowlisted USDT under N"
|
|
507
|
+
# policy would let through.
|
|
508
|
+
raw_amount = _flag("amount")
|
|
509
|
+
raw_max_usd = _flag("max-usd")
|
|
510
|
+
amount = _numeric_amount(raw_amount)
|
|
511
|
+
max_usd = _numeric_amount(raw_max_usd)
|
|
512
|
+
amount_unparsed = (raw_amount is not None and amount is None) or (
|
|
513
|
+
raw_max_usd is not None and max_usd is None
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
if repeated_recipient or repeated_flag or multi_statement or amount_unparsed:
|
|
517
|
+
return {
|
|
518
|
+
"operation": op,
|
|
519
|
+
"asset": "ambiguous",
|
|
520
|
+
"amount": None,
|
|
521
|
+
"max_usd": None,
|
|
522
|
+
"recipient_status": "ambiguous",
|
|
523
|
+
"ambiguous": True,
|
|
524
|
+
"amount_unparsed": amount_unparsed,
|
|
525
|
+
"ambiguous_reason": (
|
|
526
|
+
"multiple statements, redirection or substitution"
|
|
527
|
+
if multi_statement
|
|
528
|
+
else "repeated flag"
|
|
529
|
+
if (repeated_recipient or repeated_flag)
|
|
530
|
+
else "amount is not a plain decimal number"
|
|
531
|
+
),
|
|
532
|
+
# Conservative facts, so an asset-specific rule is still reachable.
|
|
533
|
+
# Never grant on these: recipient_status above stays "ambiguous".
|
|
534
|
+
"strict_reading": _strict_reading(
|
|
535
|
+
command, recipient_allowlist, multi_statement
|
|
536
|
+
),
|
|
537
|
+
"allowlist_scope": "USDT transfers/approvals only; never lifts ETH or ATBASH rules",
|
|
538
|
+
"note": (
|
|
539
|
+
"canonicalization withheld: the command carries more than one "
|
|
540
|
+
"transfer or flag value, an unreadable amount, or text that a "
|
|
541
|
+
"shell would expand, so no asset/amount/recipient claim here "
|
|
542
|
+
"can be trusted"
|
|
543
|
+
),
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
to = _flag("to") or _flag("recipient")
|
|
351
547
|
token = _flag("token")
|
|
352
548
|
if not token:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
549
|
+
# Only an address that is not the recipient can be the token contract.
|
|
550
|
+
# Taking the first 0x in the command picked up the RECIPIENT of a
|
|
551
|
+
# native send, resolved it to "other", and made the stricter ETH rule
|
|
552
|
+
# unreachable for every command that names an address.
|
|
553
|
+
candidates = [
|
|
554
|
+
address
|
|
555
|
+
for address in _ERC20_RE.findall(command)
|
|
556
|
+
if not to or address.lower() != to.lower()
|
|
557
|
+
]
|
|
558
|
+
if candidates:
|
|
559
|
+
token = candidates[0]
|
|
356
560
|
|
|
357
|
-
to = _flag("to") or _flag("recipient")
|
|
358
|
-
amount = _flag("amount")
|
|
359
|
-
max_usd = _flag("max-usd")
|
|
360
561
|
asset = _resolve_asset(token or "")
|
|
361
562
|
|
|
362
563
|
on_allow = bool(to) and to.lower() in recipient_allowlist
|
|
@@ -365,18 +566,16 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
|
|
|
365
566
|
else:
|
|
366
567
|
recipient_status = "external" if to else "unspecified"
|
|
367
568
|
|
|
368
|
-
op = (
|
|
369
|
-
"swap" if re.search(r"\bswap\b", command, re.IGNORECASE)
|
|
370
|
-
else "approve" if re.search(r"\b(approve|erc20)\b", command, re.IGNORECASE)
|
|
371
|
-
else "transfer"
|
|
372
|
-
)
|
|
373
|
-
|
|
374
569
|
return {
|
|
375
570
|
"operation": op,
|
|
376
571
|
"asset": asset,
|
|
377
572
|
"amount": amount,
|
|
378
573
|
"max_usd": max_usd,
|
|
379
574
|
"recipient_status": recipient_status,
|
|
575
|
+
"ambiguous": False,
|
|
576
|
+
# Always present so that its absence is never the thing that has to be
|
|
577
|
+
# noticed: here it is False, so amount=None really does mean "not given".
|
|
578
|
+
"amount_unparsed": False,
|
|
380
579
|
"allowlist_scope": "USDT transfers/approvals only; never lifts ETH or ATBASH rules",
|
|
381
580
|
"note": "canonicalized pre-judge; raw 0x addresses omitted (redacted upstream)",
|
|
382
581
|
}
|
|
@@ -416,9 +615,29 @@ def _infer_action_class(tool_name: str) -> str:
|
|
|
416
615
|
|
|
417
616
|
|
|
418
617
|
def _as_bool(value: Optional[str], default: bool) -> bool:
|
|
618
|
+
"""Parse a boolean env var, refusing to guess.
|
|
619
|
+
|
|
620
|
+
This used to be ``value.strip().lower() in {"1","true","yes","on"}``, so
|
|
621
|
+
every value outside that set read as False. ``ATBASH_ENFORCE_DECISION=``
|
|
622
|
+
(empty, the normal shape when a .env entry is left blank) or a typo like
|
|
623
|
+
``enabled`` therefore turned enforcement OFF silently, on the one setting
|
|
624
|
+
that decides whether this plugin blocks anything at all. An unrecognised
|
|
625
|
+
value now keeps the default and says so.
|
|
626
|
+
"""
|
|
419
627
|
if value is None:
|
|
420
628
|
return default
|
|
421
|
-
|
|
629
|
+
v = value.strip().lower()
|
|
630
|
+
if v in {"1", "true", "yes", "on"}:
|
|
631
|
+
return True
|
|
632
|
+
if v in {"0", "false", "no", "off"}:
|
|
633
|
+
return False
|
|
634
|
+
logger.warning(
|
|
635
|
+
"Atbash: unrecognized boolean value %r; keeping the default (%s). "
|
|
636
|
+
"Use one of 1/true/yes/on or 0/false/no/off.",
|
|
637
|
+
value,
|
|
638
|
+
default,
|
|
639
|
+
)
|
|
640
|
+
return default
|
|
422
641
|
|
|
423
642
|
|
|
424
643
|
def _normalize_verdict(raw: Any) -> str:
|
|
@@ -550,14 +769,13 @@ def _setup_telemetry() -> None:
|
|
|
550
769
|
class AtbashHermesGuard:
|
|
551
770
|
def __init__(self) -> None:
|
|
552
771
|
self.debug = _as_bool(os.getenv("ATBASH_DEBUG"), False)
|
|
553
|
-
self.fail_closed =
|
|
772
|
+
self.fail_closed = _enforcement_enabled(os.getenv("ATBASH_ENFORCE_DECISION"))
|
|
554
773
|
self.endpoint = os.getenv("ATBASH_ENDPOINT")
|
|
555
774
|
self.judge_endpoint_policy = os.getenv("ATBASH_JUDGE_ENDPOINT_POLICY")
|
|
556
775
|
self.judge_verify_pubkey = os.getenv("ATBASH_JUDGE_VERIFY_PUBKEY")
|
|
557
|
-
self.key_path = os.
|
|
776
|
+
self.key_path = os.getenv("ATBASH_KEY_PATH")
|
|
558
777
|
self.privkey = os.getenv("ATBASH_AGENT_PRIVKEY")
|
|
559
778
|
self.org_name = os.getenv("ATBASH_ORG_NAME")
|
|
560
|
-
self.request_timeout = float(os.getenv("ATBASH_REQUEST_TIMEOUT") or 60.0)
|
|
561
779
|
self.provider = os.getenv("HERMES_PROVIDER")
|
|
562
780
|
self.model = os.getenv("HERMES_MODEL")
|
|
563
781
|
self.tool_map_path = Path(
|
|
@@ -600,7 +818,7 @@ class AtbashHermesGuard:
|
|
|
600
818
|
from atbash import Atbash # type: ignore
|
|
601
819
|
from atbash.types import ToolCallInput # type: ignore
|
|
602
820
|
except Exception:
|
|
603
|
-
raise RuntimeError("atbash-sdk is required. Install with: pip install atbash-sdk==0.4.
|
|
821
|
+
raise RuntimeError("atbash-sdk is required. Install with: pip install atbash-sdk==0.4.3.dev0") from e
|
|
604
822
|
|
|
605
823
|
self.ToolCallInput = ToolCallInput
|
|
606
824
|
judge_cfg = self._judge_config()
|
|
@@ -613,7 +831,6 @@ class AtbashHermesGuard:
|
|
|
613
831
|
judge=judge_cfg,
|
|
614
832
|
org_name=self.org_name,
|
|
615
833
|
fail_closed=self.fail_closed,
|
|
616
|
-
timeout=self.request_timeout,
|
|
617
834
|
)
|
|
618
835
|
except TypeError:
|
|
619
836
|
kwargs: Dict[str, Any] = {
|
|
@@ -632,7 +849,6 @@ class AtbashHermesGuard:
|
|
|
632
849
|
judge=judge_cfg,
|
|
633
850
|
org_name=self.org_name,
|
|
634
851
|
fail_closed=self.fail_closed,
|
|
635
|
-
timeout=self.request_timeout,
|
|
636
852
|
)
|
|
637
853
|
|
|
638
854
|
def _judge_config(self) -> Optional[Dict[str, str]]:
|
|
@@ -856,7 +1072,7 @@ class AtbashHermesGuard:
|
|
|
856
1072
|
return None
|
|
857
1073
|
|
|
858
1074
|
allow_flag = _extract_allow(verdict_raw)
|
|
859
|
-
if verdict == "ALLOW" and allow_flag is
|
|
1075
|
+
if verdict == "ALLOW" and allow_flag is not False:
|
|
860
1076
|
self._snapshots[path] = new_content
|
|
861
1077
|
self._hash_store.set(path, _sha256_hex(new_content))
|
|
862
1078
|
return None
|
|
@@ -1036,20 +1252,30 @@ class AtbashHermesGuard:
|
|
|
1036
1252
|
}
|
|
1037
1253
|
return None
|
|
1038
1254
|
|
|
1255
|
+
# Strict allowlist. _normalize_verdict passes any unrecognised
|
|
1256
|
+
# string straight through (it only maps None to ERROR), and this
|
|
1257
|
+
# used to end in a bare `return None`, i.e. run the tool. So a
|
|
1258
|
+
# renamed verdict, a typo, an SDK newer than this plugin, or a
|
|
1259
|
+
# response the SDK itself marked allow=False all executed the
|
|
1260
|
+
# call. Only an exact ALLOW that the SDK also vouches for runs;
|
|
1261
|
+
# a missing allow flag is not permission.
|
|
1039
1262
|
allow_flag = _extract_allow(verdict_raw)
|
|
1040
1263
|
if verdict == "ALLOW" and allow_flag is True:
|
|
1041
1264
|
return None
|
|
1042
1265
|
|
|
1043
1266
|
logger.warning(
|
|
1044
|
-
"Atbash unusable verdict tool=%s verdict=%s allow=%s",
|
|
1267
|
+
"Atbash unusable verdict tool=%s verdict=%s allow=%s reason=%s",
|
|
1045
1268
|
tool_name,
|
|
1046
1269
|
verdict,
|
|
1047
1270
|
allow_flag,
|
|
1271
|
+
reason,
|
|
1048
1272
|
)
|
|
1049
1273
|
if self.fail_closed:
|
|
1050
1274
|
return {
|
|
1051
1275
|
"action": "block",
|
|
1052
|
-
"message":
|
|
1276
|
+
"message": (
|
|
1277
|
+
f"Blocked (unusable Atbash verdict {verdict!r}): {reason}"
|
|
1278
|
+
),
|
|
1053
1279
|
}
|
|
1054
1280
|
return None
|
|
1055
1281
|
except Exception as e:
|
|
@@ -1064,7 +1290,34 @@ class AtbashHermesGuard:
|
|
|
1064
1290
|
|
|
1065
1291
|
def register(ctx):
|
|
1066
1292
|
_setup_telemetry()
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1293
|
+
state: Dict[str, Any] = {"guard": None, "error": "guard initialization incomplete"}
|
|
1294
|
+
|
|
1295
|
+
def fail_closed_pre_tool_call(**kwargs: Any):
|
|
1296
|
+
guard = state["guard"]
|
|
1297
|
+
if guard is None:
|
|
1298
|
+
return {
|
|
1299
|
+
"action": "block",
|
|
1300
|
+
"message": f"Blocked (Atbash guard unavailable): {state['error']}",
|
|
1301
|
+
}
|
|
1302
|
+
return guard.pre_tool_call(**kwargs)
|
|
1303
|
+
|
|
1304
|
+
# Hermes isolates plugin-load exceptions and continues startup, so install
|
|
1305
|
+
# the blocking hook before any SDK/config initialization can fail.
|
|
1306
|
+
ctx.register_hook("pre_tool_call", fail_closed_pre_tool_call)
|
|
1307
|
+
try:
|
|
1308
|
+
state["guard"] = AtbashHermesGuard()
|
|
1309
|
+
except Exception as error:
|
|
1310
|
+
state["error"] = str(error)
|
|
1311
|
+
logger.error(
|
|
1312
|
+
"[atbash-hermes-plugin] guard initialization failed; "
|
|
1313
|
+
"pre_tool_call remains fail-closed err=%s",
|
|
1314
|
+
error,
|
|
1315
|
+
)
|
|
1316
|
+
return
|
|
1317
|
+
# Say which mode actually registered — operators read this line as
|
|
1318
|
+
# confirmation that enforcement is on, so it must not claim fail-closed
|
|
1319
|
+
# when ATBASH_ENFORCE_DECISION turned enforcement off.
|
|
1320
|
+
logger.info(
|
|
1321
|
+
"[atbash-hermes-plugin] registered pre_tool_call hook mode=%s",
|
|
1322
|
+
"fail-closed" if state["guard"].fail_closed else "fail-open",
|
|
1323
|
+
)
|
{atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atbash-hermes-plugin
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.10.dev1
|
|
4
4
|
Summary: Atbash safety plugin for Hermes Agent
|
|
5
5
|
Author: atbash
|
|
6
6
|
License-Expression: LicenseRef-Atbash-Proprietary
|
|
@@ -10,7 +10,7 @@ Keywords: atbash,hermes,hermes-agent,agent-safety,ai-safety,tool-guard,judge,pol
|
|
|
10
10
|
Requires-Python: <3.13,>=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: atbash-sdk==0.
|
|
13
|
+
Requires-Dist: atbash-sdk==0.5.2.dev0
|
|
14
14
|
Requires-Dist: httpx<1,>=0.27
|
|
15
15
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.29
|
|
16
16
|
Requires-Dist: opentelemetry-sdk<2,>=1.29
|
|
@@ -30,37 +30,26 @@ stopped before execution.
|
|
|
30
30
|
- Intercepts Hermes tool calls through `pre_tool_call`.
|
|
31
31
|
- Sends the tool name, arguments, command-like payload, session metadata, and
|
|
32
32
|
inferred action class to Atbash.
|
|
33
|
-
- Blocks tool execution on `BLOCK`, `DENY`, `REJECT`,
|
|
33
|
+
- Blocks tool execution on `BLOCK`, `DENY`, `REJECT`, `DISALLOW`, or `HOLD`.
|
|
34
34
|
- Persists learned Hermes tool classifications across sessions.
|
|
35
35
|
|
|
36
36
|
## Install
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
**If you installed Hermes via the official install script (`curl … | bash`):**
|
|
38
|
+
Install the plugin into the same Python environment that runs Hermes.
|
|
39
|
+
This prerelease requires Python 3.10 through 3.12, matching `atbash-sdk==0.4.3.dev0`.
|
|
42
40
|
|
|
43
41
|
```bash
|
|
44
|
-
|
|
42
|
+
pip install --pre atbash-hermes-plugin==0.4.3.dev0
|
|
45
43
|
```
|
|
46
44
|
|
|
47
|
-
|
|
45
|
+
If Hermes is installed in a virtual environment, use that environment's Python:
|
|
48
46
|
|
|
49
47
|
```bash
|
|
50
|
-
|
|
48
|
+
/path/to/hermes/venv/bin/python -m pip install --pre atbash-hermes-plugin==0.4.3.dev0
|
|
51
49
|
```
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
# pip
|
|
57
|
-
~/.hermes/hermes-agent/venv/bin/pip3 install --pre atbash-hermes-plugin
|
|
58
|
-
|
|
59
|
-
# uv
|
|
60
|
-
uv pip install --python ~/.hermes/hermes-agent/venv/bin/python --prerelease=allow atbash-hermes-plugin
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
No further activation step is needed — Hermes discovers the plugin automatically on next start.
|
|
51
|
+
Maintainers: releases use clean staged source, live PyPI monotonic checks, and
|
|
52
|
+
Trusted Publishing. See [the release process](docs/release.md).
|
|
64
53
|
|
|
65
54
|
## Configure Atbash
|
|
66
55
|
|
|
@@ -70,10 +59,10 @@ The plugin needs an Atbash agent key. Configure either `ATBASH_KEY_PATH` or
|
|
|
70
59
|
Recommended:
|
|
71
60
|
|
|
72
61
|
```bash
|
|
73
|
-
ATBASH_KEY_PATH
|
|
62
|
+
ATBASH_KEY_PATH=$HOME/.config/atbash/guard-client-key
|
|
74
63
|
```
|
|
75
64
|
|
|
76
|
-
Alternative
|
|
65
|
+
Alternative:
|
|
77
66
|
|
|
78
67
|
```bash
|
|
79
68
|
ATBASH_AGENT_PRIVKEY='{"pubkey":"...","privkey":"..."}'
|
|
@@ -81,7 +70,7 @@ ATBASH_AGENT_PRIVKEY='{"pubkey":"...","privkey":"..."}'
|
|
|
81
70
|
|
|
82
71
|
## Where To Set Environment Variables
|
|
83
72
|
|
|
84
|
-
Hermes loads environment variables from `~/.hermes/.env`.
|
|
73
|
+
Hermes commonly loads environment variables from `~/.hermes/.env`.
|
|
85
74
|
|
|
86
75
|
Create or edit that file:
|
|
87
76
|
|
|
@@ -92,7 +81,7 @@ nano ~/.hermes/.env
|
|
|
92
81
|
Add:
|
|
93
82
|
|
|
94
83
|
```bash
|
|
95
|
-
ATBASH_KEY_PATH
|
|
84
|
+
ATBASH_KEY_PATH=$HOME/.config/atbash/guard-client-key
|
|
96
85
|
ATBASH_ENFORCE_DECISION=true
|
|
97
86
|
ATBASH_DEBUG=false
|
|
98
87
|
ATBASH_ORG_NAME=your-org-name
|
|
@@ -132,11 +121,7 @@ ATBASH_DEBUG=false
|
|
|
132
121
|
ATBASH_ORG_NAME=your-org-name
|
|
133
122
|
|
|
134
123
|
# Override where learned Hermes tool classifications are saved.
|
|
135
|
-
ATBASH_TOOL_MAP_PATH
|
|
136
|
-
|
|
137
|
-
# HTTP request timeout in seconds for judge API calls. Default: 60.
|
|
138
|
-
# Raise this if you see "read operation timed out" errors on a slow endpoint.
|
|
139
|
-
ATBASH_REQUEST_TIMEOUT=60
|
|
124
|
+
ATBASH_TOOL_MAP_PATH=$HOME/.config/atbash/hermes-tool-map.json
|
|
140
125
|
```
|
|
141
126
|
|
|
142
127
|
## Telemetry
|
|
@@ -154,13 +139,19 @@ printf '{"enabled": false}\n' > ~/.config/atbash/telemetry.json
|
|
|
154
139
|
|
|
155
140
|
## Enable Or Check The Plugin
|
|
156
141
|
|
|
157
|
-
Hermes
|
|
158
|
-
|
|
142
|
+
Hermes should discover installed Python packages that expose the
|
|
143
|
+
`hermes_agent.plugins` entry point.
|
|
159
144
|
|
|
160
|
-
|
|
145
|
+
Check whether Hermes sees the plugin:
|
|
161
146
|
|
|
162
147
|
```bash
|
|
163
|
-
|
|
148
|
+
hermes plugins list | grep atbash
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
If needed, enable it:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
hermes plugins enable atbash-hermes-plugin
|
|
164
155
|
```
|
|
165
156
|
|
|
166
157
|
## Verify It Is Working
|
|
@@ -171,7 +162,7 @@ file or opening a website.
|
|
|
171
162
|
In another terminal, watch the Hermes log:
|
|
172
163
|
|
|
173
164
|
```bash
|
|
174
|
-
tail -f ~/.hermes/logs/agent.log
|
|
165
|
+
tail -f ~/.hermes/logs/agent.log
|
|
175
166
|
```
|
|
176
167
|
|
|
177
168
|
With `ATBASH_DEBUG=true`, you should see lines similar to:
|
|
@@ -245,13 +236,21 @@ Hermes sessions.
|
|
|
245
236
|
|
|
246
237
|
## Verdict Behavior
|
|
247
238
|
|
|
248
|
-
- `ALLOW`: the tool proceeds.
|
|
239
|
+
- `ALLOW` with `allow is True`: the tool proceeds. A missing `allow` flag is denied.
|
|
249
240
|
- `HOLD`: the tool is blocked with a review message.
|
|
250
241
|
- `BLOCK`, `DENY`, `REJECT`, `DISALLOW`: the tool is blocked.
|
|
251
242
|
- Atbash API error:
|
|
252
243
|
- `ATBASH_ENFORCE_DECISION=true`: fail closed and block.
|
|
253
244
|
- `ATBASH_ENFORCE_DECISION=false`: fail open and allow.
|
|
254
245
|
|
|
246
|
+
Atbash ships fail-closed on every tier. Setting `ATBASH_ENFORCE_DECISION=false`
|
|
247
|
+
inverts that for this agent: a judge outage becomes a silent allow, and the
|
|
248
|
+
governance layer stops governing for as long as it lasts. That is supported, but
|
|
249
|
+
record a written risk acceptance in the deployment's security summary before
|
|
250
|
+
turning it off, so the trade-off is auditable after an incident rather than
|
|
251
|
+
discovered during one. See decision 0003 (fail-closed default) in the dashboard
|
|
252
|
+
repo.
|
|
253
|
+
|
|
255
254
|
For `HOLD`, the user-facing block message is:
|
|
256
255
|
|
|
257
256
|
```text
|
|
@@ -266,24 +265,26 @@ user to retry after review, rather than automatically re-running the action.
|
|
|
266
265
|
|
|
267
266
|
## Troubleshooting
|
|
268
267
|
|
|
269
|
-
If
|
|
270
|
-
environment (not the system Python):
|
|
268
|
+
If Hermes does not show the plugin:
|
|
271
269
|
|
|
272
270
|
```bash
|
|
273
|
-
|
|
271
|
+
hermes plugins list | grep atbash
|
|
272
|
+
python -m pip show atbash-hermes-plugin
|
|
274
273
|
```
|
|
275
274
|
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
Make sure the package was installed into the same Python environment that runs
|
|
276
|
+
Hermes.
|
|
277
|
+
|
|
278
|
+
If Atbash verdicts are not appearing in logs:
|
|
278
279
|
|
|
279
280
|
```bash
|
|
280
281
|
ATBASH_DEBUG=true
|
|
281
282
|
```
|
|
282
283
|
|
|
283
|
-
Then restart Hermes and watch
|
|
284
|
+
Then restart Hermes and watch:
|
|
284
285
|
|
|
285
286
|
```bash
|
|
286
|
-
tail -f ~/.hermes/logs/agent.log
|
|
287
|
+
tail -f ~/.hermes/logs/agent.log
|
|
287
288
|
```
|
|
288
289
|
|
|
289
290
|
If the plugin blocks everything with an unavailable-key or authentication error,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
atbash_hermes_plugin/__init__.py,sha256=vd7kxSnqMGrU3oYttRM6bLw5WwU4Rg5UsDXFXtO0pt8,53307
|
|
2
|
+
atbash_hermes_plugin-0.4.10.dev1.dist-info/licenses/LICENSE,sha256=1vOJDJvAuOLDHJ8-Q0qDdOXZJJVli6Lr9mKKaFsOa5w,1600
|
|
3
|
+
atbash_hermes_plugin-0.4.10.dev1.dist-info/METADATA,sha256=Hc4ffzsDOASmiNASYWQ38KvfB4wt6Q4zENxAOqO3qbY,8069
|
|
4
|
+
atbash_hermes_plugin-0.4.10.dev1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
atbash_hermes_plugin-0.4.10.dev1.dist-info/entry_points.txt,sha256=YezkmasjRBXrSlXpqhSJdFhDCdBbENp-_THYtkcRTBo,67
|
|
6
|
+
atbash_hermes_plugin-0.4.10.dev1.dist-info/top_level.txt,sha256=0nT35m8jNxzbQB3CerGf5kgH7hm_pc6GLadlf69w_JM,21
|
|
7
|
+
atbash_hermes_plugin-0.4.10.dev1.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
atbash_hermes_plugin/__init__.py,sha256=ww06aPT4JT_N7ghxK6aeqqunAm-LlRVMv_DFcmwu_io,42419
|
|
2
|
-
atbash_hermes_plugin-0.4.9.dev0.dist-info/licenses/LICENSE,sha256=1vOJDJvAuOLDHJ8-Q0qDdOXZJJVli6Lr9mKKaFsOa5w,1600
|
|
3
|
-
atbash_hermes_plugin-0.4.9.dev0.dist-info/METADATA,sha256=ZtC0MWooKrBr8BNOXZKJXUVw2lZP_CMz-PLSOqEBin0,8184
|
|
4
|
-
atbash_hermes_plugin-0.4.9.dev0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
-
atbash_hermes_plugin-0.4.9.dev0.dist-info/entry_points.txt,sha256=YezkmasjRBXrSlXpqhSJdFhDCdBbENp-_THYtkcRTBo,67
|
|
6
|
-
atbash_hermes_plugin-0.4.9.dev0.dist-info/top_level.txt,sha256=0nT35m8jNxzbQB3CerGf5kgH7hm_pc6GLadlf69w_JM,21
|
|
7
|
-
atbash_hermes_plugin-0.4.9.dev0.dist-info/RECORD,,
|
{atbash_hermes_plugin-0.4.9.dev0.dist-info → atbash_hermes_plugin-0.4.10.dev1.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|