atbash-hermes-plugin 0.4.5.dev1__tar.gz → 0.4.5.dev2__tar.gz
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-0.4.5.dev2/MANIFEST.in +1 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/PKG-INFO +14 -3
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/README.md +12 -1
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin/__init__.py +291 -20
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin.egg-info/PKG-INFO +14 -3
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin.egg-info/SOURCES.txt +5 -1
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin.egg-info/requires.txt +1 -1
- atbash_hermes_plugin-0.4.5.dev2/plugin.yaml +9 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/pyproject.toml +3 -3
- atbash_hermes_plugin-0.4.5.dev2/tests/test_pre_tool_call_verdicts.py +474 -0
- atbash_hermes_plugin-0.4.5.dev2/tests/test_release_contract.py +207 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/LICENSE +0 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin.egg-info/dependency_links.txt +0 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin.egg-info/entry_points.txt +0 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin.egg-info/top_level.txt +0 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/setup.cfg +0 -0
- {atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/tests/test_memory_poisoning.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include plugin.yaml
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atbash-hermes-plugin
|
|
3
|
-
Version: 0.4.5.
|
|
3
|
+
Version: 0.4.5.dev2
|
|
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.1.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
|
|
@@ -48,6 +48,9 @@ If Hermes is installed in a virtual environment, use that environment's Python:
|
|
|
48
48
|
/path/to/hermes/venv/bin/python -m pip install --pre atbash-hermes-plugin==0.4.3.dev0
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
Maintainers: releases use clean staged source, live PyPI monotonic checks, and
|
|
52
|
+
Trusted Publishing. See [the release process](docs/release.md).
|
|
53
|
+
|
|
51
54
|
## Configure Atbash
|
|
52
55
|
|
|
53
56
|
The plugin needs an Atbash agent key. Configure either `ATBASH_KEY_PATH` or
|
|
@@ -233,13 +236,21 @@ Hermes sessions.
|
|
|
233
236
|
|
|
234
237
|
## Verdict Behavior
|
|
235
238
|
|
|
236
|
-
- `ALLOW`: the tool proceeds.
|
|
239
|
+
- `ALLOW` with `allow is True`: the tool proceeds. A missing `allow` flag is denied.
|
|
237
240
|
- `HOLD`: the tool is blocked with a review message.
|
|
238
241
|
- `BLOCK`, `DENY`, `REJECT`, `DISALLOW`: the tool is blocked.
|
|
239
242
|
- Atbash API error:
|
|
240
243
|
- `ATBASH_ENFORCE_DECISION=true`: fail closed and block.
|
|
241
244
|
- `ATBASH_ENFORCE_DECISION=false`: fail open and allow.
|
|
242
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
|
+
|
|
243
254
|
For `HOLD`, the user-facing block message is:
|
|
244
255
|
|
|
245
256
|
```text
|
|
@@ -30,6 +30,9 @@ If Hermes is installed in a virtual environment, use that environment's Python:
|
|
|
30
30
|
/path/to/hermes/venv/bin/python -m pip install --pre atbash-hermes-plugin==0.4.3.dev0
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
Maintainers: releases use clean staged source, live PyPI monotonic checks, and
|
|
34
|
+
Trusted Publishing. See [the release process](docs/release.md).
|
|
35
|
+
|
|
33
36
|
## Configure Atbash
|
|
34
37
|
|
|
35
38
|
The plugin needs an Atbash agent key. Configure either `ATBASH_KEY_PATH` or
|
|
@@ -215,13 +218,21 @@ Hermes sessions.
|
|
|
215
218
|
|
|
216
219
|
## Verdict Behavior
|
|
217
220
|
|
|
218
|
-
- `ALLOW`: the tool proceeds.
|
|
221
|
+
- `ALLOW` with `allow is True`: the tool proceeds. A missing `allow` flag is denied.
|
|
219
222
|
- `HOLD`: the tool is blocked with a review message.
|
|
220
223
|
- `BLOCK`, `DENY`, `REJECT`, `DISALLOW`: the tool is blocked.
|
|
221
224
|
- Atbash API error:
|
|
222
225
|
- `ATBASH_ENFORCE_DECISION=true`: fail closed and block.
|
|
223
226
|
- `ATBASH_ENFORCE_DECISION=false`: fail open and allow.
|
|
224
227
|
|
|
228
|
+
Atbash ships fail-closed on every tier. Setting `ATBASH_ENFORCE_DECISION=false`
|
|
229
|
+
inverts that for this agent: a judge outage becomes a silent allow, and the
|
|
230
|
+
governance layer stops governing for as long as it lasts. That is supported, but
|
|
231
|
+
record a written risk acceptance in the deployment's security summary before
|
|
232
|
+
turning it off, so the trade-off is auditable after an incident rather than
|
|
233
|
+
discovered during one. See decision 0003 (fail-closed default) in the dashboard
|
|
234
|
+
repo.
|
|
235
|
+
|
|
225
236
|
For `HOLD`, the user-facing block message is:
|
|
226
237
|
|
|
227
238
|
```text
|
{atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/atbash_hermes_plugin/__init__.py
RENAMED
|
@@ -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
|
|
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
|
+
}
|
|
350
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,7 +769,7 @@ 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")
|
|
@@ -1033,6 +1252,31 @@ class AtbashHermesGuard:
|
|
|
1033
1252
|
}
|
|
1034
1253
|
return None
|
|
1035
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.
|
|
1262
|
+
allow_flag = _extract_allow(verdict_raw)
|
|
1263
|
+
if verdict == "ALLOW" and allow_flag is True:
|
|
1264
|
+
return None
|
|
1265
|
+
|
|
1266
|
+
logger.warning(
|
|
1267
|
+
"Atbash unusable verdict tool=%s verdict=%s allow=%s reason=%s",
|
|
1268
|
+
tool_name,
|
|
1269
|
+
verdict,
|
|
1270
|
+
allow_flag,
|
|
1271
|
+
reason,
|
|
1272
|
+
)
|
|
1273
|
+
if self.fail_closed:
|
|
1274
|
+
return {
|
|
1275
|
+
"action": "block",
|
|
1276
|
+
"message": (
|
|
1277
|
+
f"Blocked (unusable Atbash verdict {verdict!r}): {reason}"
|
|
1278
|
+
),
|
|
1279
|
+
}
|
|
1036
1280
|
return None
|
|
1037
1281
|
except Exception as e:
|
|
1038
1282
|
logger.warning("Atbash guard error tool=%s err=%s", tool_name, e)
|
|
@@ -1046,7 +1290,34 @@ class AtbashHermesGuard:
|
|
|
1046
1290
|
|
|
1047
1291
|
def register(ctx):
|
|
1048
1292
|
_setup_telemetry()
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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.5.
|
|
3
|
+
Version: 0.4.5.dev2
|
|
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.1.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
|
|
@@ -48,6 +48,9 @@ If Hermes is installed in a virtual environment, use that environment's Python:
|
|
|
48
48
|
/path/to/hermes/venv/bin/python -m pip install --pre atbash-hermes-plugin==0.4.3.dev0
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
Maintainers: releases use clean staged source, live PyPI monotonic checks, and
|
|
52
|
+
Trusted Publishing. See [the release process](docs/release.md).
|
|
53
|
+
|
|
51
54
|
## Configure Atbash
|
|
52
55
|
|
|
53
56
|
The plugin needs an Atbash agent key. Configure either `ATBASH_KEY_PATH` or
|
|
@@ -233,13 +236,21 @@ Hermes sessions.
|
|
|
233
236
|
|
|
234
237
|
## Verdict Behavior
|
|
235
238
|
|
|
236
|
-
- `ALLOW`: the tool proceeds.
|
|
239
|
+
- `ALLOW` with `allow is True`: the tool proceeds. A missing `allow` flag is denied.
|
|
237
240
|
- `HOLD`: the tool is blocked with a review message.
|
|
238
241
|
- `BLOCK`, `DENY`, `REJECT`, `DISALLOW`: the tool is blocked.
|
|
239
242
|
- Atbash API error:
|
|
240
243
|
- `ATBASH_ENFORCE_DECISION=true`: fail closed and block.
|
|
241
244
|
- `ATBASH_ENFORCE_DECISION=false`: fail open and allow.
|
|
242
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
|
+
|
|
243
254
|
For `HOLD`, the user-facing block message is:
|
|
244
255
|
|
|
245
256
|
```text
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
2
3
|
README.md
|
|
4
|
+
plugin.yaml
|
|
3
5
|
pyproject.toml
|
|
4
6
|
atbash_hermes_plugin/__init__.py
|
|
5
7
|
atbash_hermes_plugin.egg-info/PKG-INFO
|
|
@@ -8,4 +10,6 @@ atbash_hermes_plugin.egg-info/dependency_links.txt
|
|
|
8
10
|
atbash_hermes_plugin.egg-info/entry_points.txt
|
|
9
11
|
atbash_hermes_plugin.egg-info/requires.txt
|
|
10
12
|
atbash_hermes_plugin.egg-info/top_level.txt
|
|
11
|
-
tests/test_memory_poisoning.py
|
|
13
|
+
tests/test_memory_poisoning.py
|
|
14
|
+
tests/test_pre_tool_call_verdicts.py
|
|
15
|
+
tests/test_release_contract.py
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
name: atbash-hermes-plugin
|
|
2
|
+
version: 0.4.5.dev2
|
|
3
|
+
description: Atbash guardrail plugin for Hermes Agent (pre-tool-call policy enforcement)
|
|
4
|
+
author: Chromia
|
|
5
|
+
entry: atbash_hermes_plugin
|
|
6
|
+
tags: ["security", "guardrails", "atbash", "policy"]
|
|
7
|
+
|
|
8
|
+
hooks:
|
|
9
|
+
pre_tool_call: true
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools
|
|
2
|
+
requires = ["setuptools==84.0.0", "wheel==0.48.0"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "atbash-hermes-plugin"
|
|
7
|
-
version = "0.4.5.
|
|
7
|
+
version = "0.4.5.dev2"
|
|
8
8
|
description = "Atbash safety plugin for Hermes Agent"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9,<3.13"
|
|
@@ -24,7 +24,7 @@ keywords = [
|
|
|
24
24
|
"policy"
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"atbash-sdk==0.
|
|
27
|
+
"atbash-sdk==0.5.1.dev0",
|
|
28
28
|
"httpx>=0.27,<1",
|
|
29
29
|
"opentelemetry-exporter-otlp-proto-http>=1.29,<2",
|
|
30
30
|
"opentelemetry-sdk>=1.29,<2",
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Regression tests for verdict handling in ``AtbashHermesGuard.pre_tool_call``.
|
|
2
|
+
|
|
3
|
+
The guard used to be a denylist: it blocked HOLD, a fixed set of block words,
|
|
4
|
+
and ERROR, then fell through to ``return None`` — which runs the tool. Any
|
|
5
|
+
verdict outside that list (a typo, a renamed verdict, a judge returning
|
|
6
|
+
something unexpected, a response the SDK marked ``allow=False``) silently
|
|
7
|
+
permitted the action.
|
|
8
|
+
|
|
9
|
+
These drive the real ``pre_tool_call`` with ``_judge`` replaced by a stub
|
|
10
|
+
returning a given judge response, so the logic under test is the real one. The
|
|
11
|
+
guard is built without ``__init__`` so no agent key, network, or atbash-sdk
|
|
12
|
+
install is required.
|
|
13
|
+
|
|
14
|
+
Uses unittest rather than pytest: the repo root carries an ``__init__.py`` (so
|
|
15
|
+
Hermes can load the plugin as a package), which pytest collects as a package
|
|
16
|
+
and fails to import on its own.
|
|
17
|
+
|
|
18
|
+
Run: python -m unittest discover -s tests -v
|
|
19
|
+
or: python tests/test_pre_tool_call_verdicts.py
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import types
|
|
24
|
+
import unittest
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from unittest.mock import patch
|
|
27
|
+
|
|
28
|
+
# Execute the plugin module into a private namespace. Loading it by path avoids
|
|
29
|
+
# the ambiguity between the repo-root __init__.py and the package of the same
|
|
30
|
+
# name underneath it.
|
|
31
|
+
_MODULE_PATH = Path(__file__).resolve().parents[1] / "atbash_hermes_plugin" / "__init__.py"
|
|
32
|
+
_plugin = types.ModuleType("atbash_hermes_plugin_under_test")
|
|
33
|
+
_plugin.__file__ = str(_MODULE_PATH)
|
|
34
|
+
exec( # noqa: S102 — loading the module under test, by design
|
|
35
|
+
compile(_MODULE_PATH.read_text(encoding="utf-8"), str(_MODULE_PATH), "exec"),
|
|
36
|
+
_plugin.__dict__,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
AtbashHermesGuard = _plugin.AtbashHermesGuard
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FakeDecision:
|
|
43
|
+
"""Mirrors the atbash-sdk Decision shape (verdict + allow + reason)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, verdict, allow=None, reason="because"):
|
|
46
|
+
self.verdict = verdict
|
|
47
|
+
self.reason = reason
|
|
48
|
+
if allow is not None:
|
|
49
|
+
self.allow = allow
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def make_guard(*, fail_closed=True, decision=None):
|
|
53
|
+
guard = AtbashHermesGuard.__new__(AtbashHermesGuard)
|
|
54
|
+
guard.debug = False
|
|
55
|
+
guard.fail_closed = fail_closed
|
|
56
|
+
guard._judge = lambda **kwargs: decision
|
|
57
|
+
return guard
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def call(guard):
|
|
61
|
+
return guard.pre_tool_call(tool_name="send_email", args={"to": "a@b.c"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_blocked(result):
|
|
65
|
+
return isinstance(result, dict) and result.get("action") == "block"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FakeContext:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self.hooks = []
|
|
71
|
+
|
|
72
|
+
def register_hook(self, name, callback):
|
|
73
|
+
self.hooks.append((name, callback))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SdkCapabilityChecks(unittest.TestCase):
|
|
77
|
+
def test_missing_guard_api_version_is_rejected(self):
|
|
78
|
+
sdk = types.SimpleNamespace()
|
|
79
|
+
|
|
80
|
+
with self.assertRaisesRegex(RuntimeError, "guard API"):
|
|
81
|
+
_plugin._assert_sdk_capabilities(sdk, object, object)
|
|
82
|
+
|
|
83
|
+
def test_tool_input_without_resolved_is_rejected(self):
|
|
84
|
+
class Atbash:
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_config(
|
|
87
|
+
cls,
|
|
88
|
+
*,
|
|
89
|
+
judge=None,
|
|
90
|
+
org_name=None,
|
|
91
|
+
fail_closed=True,
|
|
92
|
+
):
|
|
93
|
+
return cls()
|
|
94
|
+
|
|
95
|
+
def audit_tool_call(self):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
class ToolCallInput:
|
|
99
|
+
def __init__(self, *, tool_name, args=None, context=None):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
sdk = types.SimpleNamespace(GUARD_API_VERSION=1)
|
|
103
|
+
|
|
104
|
+
with self.assertRaisesRegex(RuntimeError, "resolved"):
|
|
105
|
+
_plugin._assert_sdk_capabilities(sdk, Atbash, ToolCallInput)
|
|
106
|
+
|
|
107
|
+
def test_from_config_without_fail_closed_is_rejected(self):
|
|
108
|
+
class Atbash:
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_config(cls, *, judge=None, org_name=None):
|
|
111
|
+
return cls()
|
|
112
|
+
|
|
113
|
+
def audit_tool_call(self):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
class ToolCallInput:
|
|
117
|
+
def __init__(self, *, tool_name, resolved=None):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
sdk = types.SimpleNamespace(GUARD_API_VERSION=1)
|
|
121
|
+
|
|
122
|
+
with self.assertRaisesRegex(RuntimeError, "fail_closed"):
|
|
123
|
+
_plugin._assert_sdk_capabilities(sdk, Atbash, ToolCallInput)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RegistrationMustFailClosed(unittest.TestCase):
|
|
127
|
+
def test_guard_initialization_failure_leaves_blocking_hook_installed(self):
|
|
128
|
+
ctx = FakeContext()
|
|
129
|
+
|
|
130
|
+
with patch.object(
|
|
131
|
+
_plugin.AtbashHermesGuard,
|
|
132
|
+
"__init__",
|
|
133
|
+
side_effect=RuntimeError("incompatible sdk"),
|
|
134
|
+
):
|
|
135
|
+
_plugin.register(ctx)
|
|
136
|
+
|
|
137
|
+
self.assertEqual(len(ctx.hooks), 1)
|
|
138
|
+
name, callback = ctx.hooks[0]
|
|
139
|
+
self.assertEqual(name, "pre_tool_call")
|
|
140
|
+
result = callback(tool_name="terminal", args={"command": "whoami"})
|
|
141
|
+
self.assertTrue(is_blocked(result))
|
|
142
|
+
self.assertIn("guard unavailable", result["message"].lower())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ResolvedFinancialDataMustStayBounded(unittest.TestCase):
|
|
146
|
+
def test_non_numeric_amount_flags_are_not_forwarded_as_resolved_data(self):
|
|
147
|
+
resolved = _plugin._canonicalize_financial(
|
|
148
|
+
"transfer --token USDT "
|
|
149
|
+
"--amount ghp_abcdefghijklmnopqrstuvwxyzABCDEFGH "
|
|
150
|
+
"--max-usd=Authorization:Bearer-secret",
|
|
151
|
+
set(),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self.assertIsNotNone(resolved)
|
|
155
|
+
self.assertIsNone(resolved["amount"])
|
|
156
|
+
self.assertIsNone(resolved["max_usd"])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
ALLOWLISTED = "0xa1ce00000000000000000000000000000000beef"
|
|
160
|
+
ATTACKER = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
|
161
|
+
USDT_CONTRACT = "0xdac17f958d2ee523a2206206994597c13d831ec7"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ResolvedFinancialMustNotOverstateWhatItKnows(unittest.TestCase):
|
|
165
|
+
"""`resolved` is the pre-digested block the judge rules on, and raw 0x
|
|
166
|
+
addresses are redacted out of args before the judge sees them — so a
|
|
167
|
+
canonicalization that reads only the FIRST occurrence of a flag lets a
|
|
168
|
+
prompt-injected command assert an allowlisted 1-unit transfer while the
|
|
169
|
+
command that actually runs moves everything to an attacker address."""
|
|
170
|
+
|
|
171
|
+
def test_duplicated_recipient_flag_is_not_asserted_as_allowlisted(self):
|
|
172
|
+
resolved = _plugin._canonicalize_financial(
|
|
173
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount 1 "
|
|
174
|
+
f"--to {ATTACKER} --amount 999999",
|
|
175
|
+
{ALLOWLISTED},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
self.assertIsNotNone(resolved)
|
|
179
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
180
|
+
self.assertIsNone(resolved["amount"])
|
|
181
|
+
|
|
182
|
+
def test_chained_statements_are_not_asserted_as_allowlisted(self):
|
|
183
|
+
for separator in [";", "&&", "||", "|", "\n"]:
|
|
184
|
+
with self.subTest(separator=separator):
|
|
185
|
+
resolved = _plugin._canonicalize_financial(
|
|
186
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount 1 "
|
|
187
|
+
f"{separator} transfer --token USDT --to {ATTACKER} "
|
|
188
|
+
"--amount 999999",
|
|
189
|
+
{ALLOWLISTED},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
self.assertIsNotNone(resolved)
|
|
193
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
194
|
+
self.assertIsNone(resolved["amount"])
|
|
195
|
+
|
|
196
|
+
def test_command_substitution_is_not_asserted_as_allowlisted(self):
|
|
197
|
+
resolved = _plugin._canonicalize_financial(
|
|
198
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount $(cat /tmp/amt)",
|
|
199
|
+
{ALLOWLISTED},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
self.assertIsNotNone(resolved)
|
|
203
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
204
|
+
|
|
205
|
+
def test_one_character_separators_are_not_asserted_as_allowlisted(self):
|
|
206
|
+
"""`&&` was caught but a bare `&` was not, `$(` was but `${` was not,
|
|
207
|
+
and no redirection operator was. `cmd1 & cmd2` backgrounds the first and
|
|
208
|
+
runs the second exactly like `;`; `${VAR}` and `$VAR` expand to text the
|
|
209
|
+
canonicalizer never sees, and CLI parsers are last-flag-wins."""
|
|
210
|
+
for tail in [
|
|
211
|
+
"& drain.sh",
|
|
212
|
+
"> /etc/cron.d/atbash",
|
|
213
|
+
">> /root/.bashrc",
|
|
214
|
+
"< /tmp/payload",
|
|
215
|
+
"${EXTRA}",
|
|
216
|
+
"$EXTRA",
|
|
217
|
+
]:
|
|
218
|
+
with self.subTest(tail=tail):
|
|
219
|
+
resolved = _plugin._canonicalize_financial(
|
|
220
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount 1 {tail}",
|
|
221
|
+
{ALLOWLISTED},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
self.assertIsNotNone(resolved)
|
|
225
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
226
|
+
self.assertIsNone(resolved["amount"])
|
|
227
|
+
self.assertTrue(resolved["ambiguous"])
|
|
228
|
+
|
|
229
|
+
def test_ambiguous_block_keeps_the_strict_concrete_reading(self):
|
|
230
|
+
"""Replacing every fact with "ambiguous" makes an asset-specific rule
|
|
231
|
+
("never lifts ETH or ATBASH rules") unreachable for any command that
|
|
232
|
+
carries a separator. The conservative reading has to survive."""
|
|
233
|
+
resolved = _plugin._canonicalize_financial(
|
|
234
|
+
f"transfer --token ETH --to {ATTACKER} --amount 999 ; echo hi",
|
|
235
|
+
{ALLOWLISTED},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
self.assertEqual(resolved["asset"], "ambiguous")
|
|
239
|
+
strict = resolved["strict_reading"]
|
|
240
|
+
self.assertEqual(strict["assets_named"], ["ETH"])
|
|
241
|
+
self.assertEqual(strict["max_amount_named"], "999")
|
|
242
|
+
self.assertEqual(strict["recipients_named"], 1)
|
|
243
|
+
self.assertFalse(strict["all_named_recipients_allowlisted"])
|
|
244
|
+
self.assertTrue(strict["may_hide_other_actions"])
|
|
245
|
+
|
|
246
|
+
def test_strict_reading_never_reads_as_a_clean_bill_of_health(self):
|
|
247
|
+
"""A chained command whose *named* parts are all benign must still say
|
|
248
|
+
that a statement it never parsed can run."""
|
|
249
|
+
resolved = _plugin._canonicalize_financial(
|
|
250
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount 1 & drain.sh",
|
|
251
|
+
{ALLOWLISTED},
|
|
252
|
+
)
|
|
253
|
+
strict = resolved["strict_reading"]
|
|
254
|
+
|
|
255
|
+
self.assertTrue(strict["all_named_recipients_allowlisted"])
|
|
256
|
+
self.assertTrue(strict["may_hide_other_actions"])
|
|
257
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
258
|
+
|
|
259
|
+
def test_single_unambiguous_transfer_still_resolves_normally(self):
|
|
260
|
+
resolved = _plugin._canonicalize_financial(
|
|
261
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount 1",
|
|
262
|
+
{ALLOWLISTED},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
self.assertEqual(resolved["asset"], "USDT")
|
|
266
|
+
self.assertEqual(resolved["amount"], "1")
|
|
267
|
+
self.assertEqual(resolved["recipient_status"], "allowlisted")
|
|
268
|
+
|
|
269
|
+
def test_native_send_resolves_as_eth_not_other(self):
|
|
270
|
+
"""No --token means a native ETH transfer. Inferring the token from the
|
|
271
|
+
first 0x in the command picks up the RECIPIENT, so the stricter ETH
|
|
272
|
+
rule could never fire for any command carrying an address."""
|
|
273
|
+
resolved = _plugin._canonicalize_financial(
|
|
274
|
+
f"send --to {ATTACKER} --amount 50",
|
|
275
|
+
set(),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
self.assertEqual(resolved["asset"], "ETH")
|
|
279
|
+
|
|
280
|
+
def test_positional_contract_address_still_names_the_asset(self):
|
|
281
|
+
resolved = _plugin._canonicalize_financial(
|
|
282
|
+
f"erc20 approve {USDT_CONTRACT} --amount 100",
|
|
283
|
+
set(),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self.assertEqual(resolved["asset"], "USDT")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class UnparsableAmountsMustNotReadAsNoAmount(unittest.TestCase):
|
|
290
|
+
"""Dropping an unreadable amount to None is right — secret-shaped values
|
|
291
|
+
must never reach the un-redacted `resolved` block — but "absent" and
|
|
292
|
+
"present and unreadable" must not be the same signal. `--amount 1e9` next to
|
|
293
|
+
recipient_status="allowlisted" tells an "allow allowlisted USDT under N"
|
|
294
|
+
policy that no amount was named at all."""
|
|
295
|
+
|
|
296
|
+
def test_non_decimal_amount_is_not_a_clean_allowlisted_no_amount_block(self):
|
|
297
|
+
for value in ["1e9", "1_000", "1,000", "0x1e", "all", "9" * 65]:
|
|
298
|
+
with self.subTest(amount=value):
|
|
299
|
+
resolved = _plugin._canonicalize_financial(
|
|
300
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount {value}",
|
|
301
|
+
{ALLOWLISTED},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
305
|
+
self.assertIsNone(resolved["amount"])
|
|
306
|
+
self.assertTrue(resolved["amount_unparsed"])
|
|
307
|
+
self.assertTrue(resolved["ambiguous"])
|
|
308
|
+
|
|
309
|
+
def test_unreadable_max_usd_is_flagged_too(self):
|
|
310
|
+
resolved = _plugin._canonicalize_financial(
|
|
311
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount 1 --max-usd 1e9",
|
|
312
|
+
{ALLOWLISTED},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
self.assertNotEqual(resolved["recipient_status"], "allowlisted")
|
|
316
|
+
self.assertTrue(resolved["amount_unparsed"])
|
|
317
|
+
|
|
318
|
+
def test_absent_amount_is_reported_as_absent_not_unparsable(self):
|
|
319
|
+
resolved = _plugin._canonicalize_financial(
|
|
320
|
+
f"transfer --token USDT --to {ALLOWLISTED}",
|
|
321
|
+
{ALLOWLISTED},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
self.assertIsNone(resolved["amount"])
|
|
325
|
+
self.assertFalse(resolved["amount_unparsed"])
|
|
326
|
+
self.assertFalse(resolved["ambiguous"])
|
|
327
|
+
|
|
328
|
+
def test_unicode_digits_are_not_a_numeric_amount(self):
|
|
329
|
+
"""In Python `\\d` matches every Unicode decimal digit, so Arabic-Indic
|
|
330
|
+
digits passed the numeric check and were forwarded verbatim."""
|
|
331
|
+
arabic_digits = "١٢٣"
|
|
332
|
+
self.assertIsNone(_plugin._numeric_amount(arabic_digits))
|
|
333
|
+
|
|
334
|
+
resolved = _plugin._canonicalize_financial(
|
|
335
|
+
f"transfer --token USDT --to {ALLOWLISTED} --amount {arabic_digits}",
|
|
336
|
+
{ALLOWLISTED},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
self.assertIsNone(resolved["amount"])
|
|
340
|
+
self.assertTrue(resolved["amount_unparsed"])
|
|
341
|
+
self.assertNotIn(arabic_digits, str(resolved))
|
|
342
|
+
|
|
343
|
+
def test_plain_decimals_still_parse(self):
|
|
344
|
+
for value in ["1", "0", "12.5", ".5", "1000"]:
|
|
345
|
+
with self.subTest(amount=value):
|
|
346
|
+
self.assertEqual(_plugin._numeric_amount(value), value)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class EnforcementMustBeOptOutNotOptIn(unittest.TestCase):
|
|
350
|
+
"""ATBASH_ENFORCE_DECISION guards every fail-open branch. An empty value —
|
|
351
|
+
what docker-compose passes for an unset host variable — or a typo used to
|
|
352
|
+
read as false and silently disabled enforcement."""
|
|
353
|
+
|
|
354
|
+
def test_unrecognized_values_keep_enforcement_on(self):
|
|
355
|
+
for value in [None, "", " ", "y", "t", "enabled", "strict", "TRUE", "on"]:
|
|
356
|
+
with self.subTest(value=value):
|
|
357
|
+
self.assertTrue(_plugin._enforcement_enabled(value))
|
|
358
|
+
|
|
359
|
+
def test_explicit_falsey_values_disable_enforcement(self):
|
|
360
|
+
for value in ["0", "false", "FALSE", "no", "off", " off "]:
|
|
361
|
+
with self.subTest(value=value):
|
|
362
|
+
self.assertFalse(_plugin._enforcement_enabled(value))
|
|
363
|
+
|
|
364
|
+
def test_debug_flag_stays_opt_in(self):
|
|
365
|
+
"""Only enforcement inverts; a verbose-logging toggle must not turn
|
|
366
|
+
itself on because someone wrote ATBASH_DEBUG=maybe."""
|
|
367
|
+
self.assertFalse(_plugin._as_bool("maybe", False))
|
|
368
|
+
self.assertTrue(_plugin._as_bool("true", False))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class RegistrationLogMustStateTheRealMode(unittest.TestCase):
|
|
372
|
+
"""An operator tailing the log per the README treats the registration line
|
|
373
|
+
as confirmation that enforcement is on; it must not say fail-closed when
|
|
374
|
+
the guard is running fail-open."""
|
|
375
|
+
|
|
376
|
+
def _register_with_mode(self, fail_closed):
|
|
377
|
+
ctx = FakeContext()
|
|
378
|
+
|
|
379
|
+
def fake_init(guard_self):
|
|
380
|
+
guard_self.fail_closed = fail_closed
|
|
381
|
+
|
|
382
|
+
with patch.object(_plugin.AtbashHermesGuard, "__init__", fake_init):
|
|
383
|
+
with self.assertLogs(_plugin.logger, level="INFO") as captured:
|
|
384
|
+
_plugin.register(ctx)
|
|
385
|
+
return "\n".join(captured.output).lower()
|
|
386
|
+
|
|
387
|
+
def test_fail_open_registration_is_announced_as_fail_open(self):
|
|
388
|
+
output = self._register_with_mode(False)
|
|
389
|
+
self.assertIn("fail-open", output)
|
|
390
|
+
|
|
391
|
+
def test_fail_closed_registration_still_says_fail_closed(self):
|
|
392
|
+
output = self._register_with_mode(True)
|
|
393
|
+
self.assertIn("fail-closed", output)
|
|
394
|
+
self.assertNotIn("fail-open", output)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class UnrecognizedVerdictsMustNotRun(unittest.TestCase):
|
|
399
|
+
def test_unrecognized_verdict_blocks_when_fail_closed(self):
|
|
400
|
+
for verdict in [
|
|
401
|
+
"MAYBE",
|
|
402
|
+
"",
|
|
403
|
+
"UNKNOWN",
|
|
404
|
+
"PENDING",
|
|
405
|
+
"NO VERDICT",
|
|
406
|
+
"GREEN",
|
|
407
|
+
"OK",
|
|
408
|
+
"PASS",
|
|
409
|
+
"ALLOWED",
|
|
410
|
+
]:
|
|
411
|
+
with self.subTest(verdict=verdict):
|
|
412
|
+
result = call(make_guard(decision=FakeDecision(verdict)))
|
|
413
|
+
self.assertTrue(
|
|
414
|
+
is_blocked(result),
|
|
415
|
+
f"verdict {verdict!r} must not be allowed through, got {result!r}",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def test_allow_contradicted_by_allow_false_blocks(self):
|
|
419
|
+
"""ALLOW while the SDK says allow=False is incoherent — must not run."""
|
|
420
|
+
result = call(make_guard(decision=FakeDecision("ALLOW", allow=False)))
|
|
421
|
+
self.assertTrue(is_blocked(result))
|
|
422
|
+
|
|
423
|
+
def test_none_response_blocks(self):
|
|
424
|
+
"""_normalize_verdict maps None to ERROR; fail_closed must block it."""
|
|
425
|
+
self.assertTrue(is_blocked(call(make_guard(decision=None))))
|
|
426
|
+
|
|
427
|
+
def test_unrecognized_verdict_respects_fail_open_opt_out(self):
|
|
428
|
+
"""The fix tightens the default; it does not override a deliberate
|
|
429
|
+
ATBASH_ENFORCE_DECISION=false."""
|
|
430
|
+
result = call(make_guard(fail_closed=False, decision=FakeDecision("MAYBE")))
|
|
431
|
+
self.assertIsNone(result)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class LegitimateOutcomesUnchanged(unittest.TestCase):
|
|
435
|
+
def test_allow_runs_the_tool(self):
|
|
436
|
+
self.assertIsNone(call(make_guard(decision=FakeDecision("ALLOW", allow=True))))
|
|
437
|
+
|
|
438
|
+
def test_allow_without_an_allow_field_blocks(self):
|
|
439
|
+
"""Missing allow is not permission. Only allow is True may run."""
|
|
440
|
+
result = call(make_guard(decision=FakeDecision("ALLOW")))
|
|
441
|
+
self.assertTrue(is_blocked(result))
|
|
442
|
+
|
|
443
|
+
def test_hold_blocks_with_review_message(self):
|
|
444
|
+
result = call(make_guard(decision=FakeDecision("HOLD")))
|
|
445
|
+
self.assertTrue(is_blocked(result))
|
|
446
|
+
self.assertIn("held for operator review", result["message"].lower())
|
|
447
|
+
|
|
448
|
+
def test_block_words_block(self):
|
|
449
|
+
for verdict in ["BLOCK", "DENY", "REJECT", "DISALLOW"]:
|
|
450
|
+
with self.subTest(verdict=verdict):
|
|
451
|
+
result = call(make_guard(decision=FakeDecision(verdict)))
|
|
452
|
+
self.assertTrue(is_blocked(result))
|
|
453
|
+
self.assertIn("Blocked by Atbash policy", result["message"])
|
|
454
|
+
|
|
455
|
+
def test_error_blocks_only_when_fail_closed(self):
|
|
456
|
+
self.assertTrue(is_blocked(call(make_guard(decision=FakeDecision("ERROR")))))
|
|
457
|
+
self.assertIsNone(
|
|
458
|
+
call(make_guard(fail_closed=False, decision=FakeDecision("ERROR")))
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def test_judge_exception_still_blocks_when_fail_closed(self):
|
|
462
|
+
guard = make_guard()
|
|
463
|
+
|
|
464
|
+
def boom(**kwargs):
|
|
465
|
+
raise RuntimeError("judge unreachable")
|
|
466
|
+
|
|
467
|
+
guard._judge = boom
|
|
468
|
+
result = call(guard)
|
|
469
|
+
self.assertTrue(is_blocked(result))
|
|
470
|
+
self.assertIn("Atbash unavailable", result["message"])
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
if __name__ == "__main__":
|
|
474
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import tarfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from packaging.version import Version
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SPEC = importlib.util.spec_from_file_location("release", ROOT / "scripts/release.py")
|
|
17
|
+
assert SPEC and SPEC.loader
|
|
18
|
+
release = importlib.util.module_from_spec(SPEC)
|
|
19
|
+
SPEC.loader.exec_module(release)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _source_files(root: Path, version: str = "0.4.5") -> None:
|
|
23
|
+
(root / "pyproject.toml").write_text(
|
|
24
|
+
f'[project]\nname = "atbash-hermes-plugin"\nversion = "{version}"\n',
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
)
|
|
27
|
+
(root / "plugin.yaml").write_text(
|
|
28
|
+
f"name: atbash-hermes-plugin\nversion: {version}\n",
|
|
29
|
+
encoding="utf-8",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_candidate_versions_use_full_history_and_never_reuse_a_gap() -> None:
|
|
34
|
+
releases = [Version("0.4.5"), Version("0.4.6.dev0"), Version("0.4.6.dev2")]
|
|
35
|
+
assert release.next_version("dev", Version("0.4.5"), releases) == Version("0.4.6.dev3")
|
|
36
|
+
assert release.next_version("latest", Version("0.4.5"), releases) == Version("0.4.6")
|
|
37
|
+
|
|
38
|
+
prereleases = [Version("0.4.5"), Version("0.4.6rc1")]
|
|
39
|
+
assert release.next_version("latest", Version("0.4.5"), prereleases) == Version("0.4.6")
|
|
40
|
+
assert release.next_version("dev", Version("0.4.5"), prereleases) == Version("0.4.7.dev0")
|
|
41
|
+
|
|
42
|
+
untagged_dev = [Version("0.4.5"), Version("0.4.8.dev4")]
|
|
43
|
+
assert release.next_version("dev", Version("0.4.5"), untagged_dev) == Version("0.4.8.dev5")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_source_version_is_exact_and_atomic(tmp_path: Path) -> None:
|
|
47
|
+
_source_files(tmp_path)
|
|
48
|
+
assert release.read_source_version(tmp_path) == Version("0.4.5")
|
|
49
|
+
(tmp_path / "plugin.yaml").write_text(
|
|
50
|
+
"name: atbash-hermes-plugin\nversion: 0.4.4\n",
|
|
51
|
+
encoding="utf-8",
|
|
52
|
+
)
|
|
53
|
+
with pytest.raises(release.ReleaseError, match="versions disagree"):
|
|
54
|
+
release.read_source_version(tmp_path)
|
|
55
|
+
_source_files(tmp_path, "0.4.6rc1")
|
|
56
|
+
with pytest.raises(release.ReleaseError, match="exact X.Y.Z"):
|
|
57
|
+
release.read_source_version(tmp_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_registry_failure_and_unsupported_history_fail_closed(
|
|
61
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
62
|
+
) -> None:
|
|
63
|
+
def fail(*_args: object, **_kwargs: object) -> None:
|
|
64
|
+
raise OSError("offline")
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr(release.urllib.request, "urlopen", fail)
|
|
67
|
+
with pytest.raises(release.ReleaseError, match="could not verify"):
|
|
68
|
+
release.load_releases()
|
|
69
|
+
with pytest.raises(release.ReleaseError, match="unsupported release base"):
|
|
70
|
+
release.next_version("dev", Version("0.4"), [Version("0.4.5")])
|
|
71
|
+
with pytest.raises(release.ReleaseError, match="unsupported release base"):
|
|
72
|
+
release.next_version("latest", Version("0.4.5"), [Version("1!0.4.5")])
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(
|
|
75
|
+
release.urllib.request,
|
|
76
|
+
"urlopen",
|
|
77
|
+
lambda *_args, **_kwargs: _Response(
|
|
78
|
+
json.dumps({"releases": {"0.4.5": [], "1!0.4.5": []}}).encode()
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
with pytest.raises(release.ReleaseError, match="unsupported version"):
|
|
82
|
+
release.load_releases()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_staging_changes_both_manifests_only_in_the_copy(tmp_path: Path) -> None:
|
|
86
|
+
source = tmp_path / "source"
|
|
87
|
+
staged = tmp_path / "staged"
|
|
88
|
+
source.mkdir()
|
|
89
|
+
staged.mkdir()
|
|
90
|
+
_source_files(source)
|
|
91
|
+
_source_files(staged)
|
|
92
|
+
release.set_staged_version(staged, Version("0.4.6.dev0"))
|
|
93
|
+
assert release.read_source_version(source) == Version("0.4.5")
|
|
94
|
+
assert release.read_source_version(staged) == Version("0.4.6.dev0")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_publish_workflow_fails_closed_outside_main() -> None:
|
|
98
|
+
workflow = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8")
|
|
99
|
+
guard = 'run: test "$GITHUB_REF" = refs/heads/main'
|
|
100
|
+
assert guard in workflow
|
|
101
|
+
assert workflow.index(guard) < workflow.index("actions/checkout@")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _metadata(name: str, version: str) -> bytes:
|
|
105
|
+
return f"Metadata-Version: 2.4\nName: {name}\nVersion: {version}\n".encode()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _valid_dist(root: Path, version: str = "0.4.6") -> None:
|
|
109
|
+
wheel = root / f"atbash_hermes_plugin-{version}-py3-none-any.whl"
|
|
110
|
+
dist_info = f"atbash_hermes_plugin-{version}.dist-info"
|
|
111
|
+
with zipfile.ZipFile(wheel, "w") as archive:
|
|
112
|
+
archive.writestr(f"{dist_info}/METADATA", _metadata(release.PROJECT, version))
|
|
113
|
+
archive.writestr(
|
|
114
|
+
f"{dist_info}/entry_points.txt",
|
|
115
|
+
"[hermes_agent.plugins]\natbash-hermes-plugin = atbash_hermes_plugin\n",
|
|
116
|
+
)
|
|
117
|
+
archive.writestr("atbash_hermes_plugin/__init__.py", "PLUGIN = True\n")
|
|
118
|
+
|
|
119
|
+
sdist = root / f"atbash_hermes_plugin-{version}.tar.gz"
|
|
120
|
+
prefix = f"atbash_hermes_plugin-{version}"
|
|
121
|
+
files = {
|
|
122
|
+
"PKG-INFO": _metadata(release.PROJECT, version),
|
|
123
|
+
"MANIFEST.in": b"include plugin.yaml\n",
|
|
124
|
+
"pyproject.toml": b"[project]\n",
|
|
125
|
+
"plugin.yaml": f"version: {version}\n".encode(),
|
|
126
|
+
"atbash_hermes_plugin/__init__.py": b"PLUGIN = True\n",
|
|
127
|
+
}
|
|
128
|
+
with tarfile.open(sdist, "w:gz") as archive:
|
|
129
|
+
for name, payload in files.items():
|
|
130
|
+
info = tarfile.TarInfo(f"{prefix}/{name}")
|
|
131
|
+
info.size = len(payload)
|
|
132
|
+
archive.addfile(info, io.BytesIO(payload))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_distribution_verifier_requires_exact_names_module_and_entry_point(
|
|
136
|
+
tmp_path: Path,
|
|
137
|
+
) -> None:
|
|
138
|
+
_valid_dist(tmp_path)
|
|
139
|
+
release.verify_dist(tmp_path, Version("0.4.6"))
|
|
140
|
+
wheel = tmp_path / "atbash_hermes_plugin-0.4.6-py3-none-any.whl"
|
|
141
|
+
with zipfile.ZipFile(wheel, "w") as archive:
|
|
142
|
+
archive.writestr(
|
|
143
|
+
"atbash_hermes_plugin-0.4.6.dist-info/METADATA",
|
|
144
|
+
_metadata(release.PROJECT, "0.4.6"),
|
|
145
|
+
)
|
|
146
|
+
with pytest.raises(release.ReleaseError, match="importable plugin module"):
|
|
147
|
+
release.verify_dist(tmp_path, Version("0.4.6"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_distribution_verifier_rejects_extra_or_misnamed_artifacts(tmp_path: Path) -> None:
|
|
151
|
+
_valid_dist(tmp_path)
|
|
152
|
+
(tmp_path / "unexpected.txt").write_text("no", encoding="utf-8")
|
|
153
|
+
with pytest.raises(release.ReleaseError, match="artifact names must be exact"):
|
|
154
|
+
release.verify_dist(tmp_path, Version("0.4.6"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _Response(io.BytesIO):
|
|
158
|
+
status = 200
|
|
159
|
+
|
|
160
|
+
def __enter__(self) -> "_Response":
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *_args: object) -> None:
|
|
164
|
+
self.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _registry_response(dist: Path, *, corrupt: bool = False) -> _Response:
|
|
168
|
+
urls = []
|
|
169
|
+
for path in sorted(dist.iterdir()):
|
|
170
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
171
|
+
if corrupt and path.suffix == ".whl":
|
|
172
|
+
digest = "0" * 64
|
|
173
|
+
urls.append({"filename": path.name, "digests": {"sha256": digest}})
|
|
174
|
+
return _Response(json.dumps({"urls": urls}).encode())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_uploaded_artifacts_are_retried_then_digest_matched(tmp_path: Path) -> None:
|
|
178
|
+
_valid_dist(tmp_path)
|
|
179
|
+
calls: list[str] = []
|
|
180
|
+
sleeps: list[float] = []
|
|
181
|
+
|
|
182
|
+
def opener(url: str, **_kwargs: object) -> _Response:
|
|
183
|
+
calls.append(url)
|
|
184
|
+
return _registry_response(tmp_path, corrupt=len(calls) == 1)
|
|
185
|
+
|
|
186
|
+
release.verify_uploaded(
|
|
187
|
+
tmp_path,
|
|
188
|
+
Version("0.4.6"),
|
|
189
|
+
attempts=2,
|
|
190
|
+
delay_seconds=0.25,
|
|
191
|
+
opener=opener,
|
|
192
|
+
sleeper=sleeps.append,
|
|
193
|
+
)
|
|
194
|
+
assert calls == [release.PYPI_VERSION_URL.format(version="0.4.6")] * 2
|
|
195
|
+
assert sleeps == [0.25]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_uploaded_artifact_mismatch_fails_closed(tmp_path: Path) -> None:
|
|
199
|
+
_valid_dist(tmp_path)
|
|
200
|
+
with pytest.raises(release.ReleaseError, match="did not converge"):
|
|
201
|
+
release.verify_uploaded(
|
|
202
|
+
tmp_path,
|
|
203
|
+
Version("0.4.6"),
|
|
204
|
+
attempts=1,
|
|
205
|
+
opener=lambda *_args, **_kwargs: _registry_response(tmp_path, corrupt=True),
|
|
206
|
+
sleeper=lambda _delay: None,
|
|
207
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{atbash_hermes_plugin-0.4.5.dev1 → atbash_hermes_plugin-0.4.5.dev2}/tests/test_memory_poisoning.py
RENAMED
|
File without changes
|