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.
@@ -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
- m = re.search(rf"--{name}[= ]+(\S+)", command)
349
- return m.group(1).strip("'\"") if m else None
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
- m = _ERC20_RE.search(command)
354
- if m:
355
- token = m.group(1)
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
- return value.strip().lower() in {"1", "true", "yes", "on"}
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 = _as_bool(os.getenv("ATBASH_ENFORCE_DECISION"), True)
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.path.expandvars(os.path.expanduser(os.getenv("ATBASH_KEY_PATH") or "")) or None
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.10.dev0") from e
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 True:
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": f"Blocked (unrecognized verdict {verdict!r}): {reason}",
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
- guard = AtbashHermesGuard()
1068
- guard._run_boot_probe()
1069
- ctx.register_hook("pre_tool_call", guard.pre_tool_call)
1070
- logger.info("[atbash-hermes-plugin] registered pre_tool_call hook")
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
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atbash-hermes-plugin
3
- Version: 0.4.9.dev0
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.4.10.dev0
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`, or `DISALLOW`; holds it for operator review on `HOLD`.
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
- Hermes manages its own Python environment. Install the plugin directly into it —
39
- do **not** use your system `pip` — on macOS and some Linux distros it will be blocked by the OS.
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
- ~/.hermes/hermes-agent/venv/bin/pip3 install atbash-hermes-plugin
42
+ pip install --pre atbash-hermes-plugin==0.4.3.dev0
45
43
  ```
46
44
 
47
- **If you installed Hermes via `uv`:**
45
+ If Hermes is installed in a virtual environment, use that environment's Python:
48
46
 
49
47
  ```bash
50
- uv pip install --python ~/.hermes/hermes-agent/venv/bin/python atbash-hermes-plugin
48
+ /path/to/hermes/venv/bin/python -m pip install --pre atbash-hermes-plugin==0.4.3.dev0
51
49
  ```
52
50
 
53
- To install the latest dev release, add `--pre` (pip) or `--prerelease=allow` (uv):
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=~/.config/atbash/guard-client-key
62
+ ATBASH_KEY_PATH=$HOME/.config/atbash/guard-client-key
74
63
  ```
75
64
 
76
- Alternative (paste the key JSON directly):
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=~/.config/atbash/guard-client-key
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=~/.config/atbash/hermes-tool-map.json
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 discovers the plugin automatically via Python entry points — no manual
158
- enable step is needed. Restarting Hermes after installation is sufficient.
142
+ Hermes should discover installed Python packages that expose the
143
+ `hermes_agent.plugins` entry point.
159
144
 
160
- To confirm the package is installed in the right environment:
145
+ Check whether Hermes sees the plugin:
161
146
 
162
147
  ```bash
163
- ~/.hermes/hermes-agent/venv/bin/pip3 show atbash-hermes-plugin
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 | grep -i atbash
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 the plugin is not being picked up, confirm it is installed in the Hermes
270
- environment (not the system Python):
268
+ If Hermes does not show the plugin:
271
269
 
272
270
  ```bash
273
- ~/.hermes/hermes-agent/venv/bin/pip3 show atbash-hermes-plugin
271
+ hermes plugins list | grep atbash
272
+ python -m pip show atbash-hermes-plugin
274
273
  ```
275
274
 
276
- If Atbash verdicts are not appearing in logs, enable debug mode by adding this
277
- to `~/.hermes/.env`:
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 in a second terminal:
284
+ Then restart Hermes and watch:
284
285
 
285
286
  ```bash
286
- tail -f ~/.hermes/logs/agent.log | grep -i atbash
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,,