atbash-hermes-plugin 0.9.12.dev0__py3-none-any.whl → 0.12.0.dev0__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.
@@ -1,19 +1,30 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import atexit
4
+ import base64
4
5
  import hashlib
5
6
  import json
6
7
  import logging
7
8
  import inspect
9
+ import ntpath
8
10
  import os
9
11
  import re
12
+ import sys
13
+ import threading
10
14
  import time
11
15
  from pathlib import Path
12
- from typing import Any, Dict, Optional
16
+ from typing import Any, Dict, Iterable, Optional
13
17
 
14
18
  logger = logging.getLogger("atbash-hermes-plugin")
15
19
  _TELEMETRY_SHUTDOWN_REGISTERED = False
16
20
 
21
+ try:
22
+ from atbash_core import redact_secrets as _redact_secrets # type: ignore
23
+ _REDACT_AVAILABLE = True
24
+ except Exception:
25
+ _redact_secrets = None # type: ignore
26
+ _REDACT_AVAILABLE = False
27
+
17
28
  HOLD_MESSAGE = (
18
29
  "Action held for operator review. The agent will not be jailed — please approve "
19
30
  "or reject this request from the Atbash dashboard, then ask the agent to try "
@@ -47,17 +58,32 @@ def _load_tool_map(path: Path) -> Dict[str, str]:
47
58
  if not isinstance(raw, dict):
48
59
  logger.warning("Ignoring invalid Atbash Hermes tool map path=%s", path)
49
60
  return {}
50
- return {
61
+ entries = {
51
62
  str(tool_name): str(action_class)
52
63
  for tool_name, action_class in raw.items()
53
64
  if isinstance(tool_name, str) and isinstance(action_class, str)
54
65
  }
66
+ # Cap learned entries (C-HERMES-13): an agent naming thousands of fake
67
+ # tools must not grow this file without bound. Defaults always survive;
68
+ # learned entries keep the most recently seen (dict order).
69
+ if len(entries) > _TOOL_MAP_MAX_ENTRIES:
70
+ entries = dict(list(entries.items())[-_TOOL_MAP_MAX_ENTRIES:])
71
+ return entries
72
+
73
+
74
+ _TOOL_MAP_MAX_ENTRIES = 512
75
+ _TOOL_MAP_LOCK = threading.Lock()
55
76
 
56
77
 
57
78
  def _save_tool_map(path: Path, tool_map: Dict[str, str]) -> None:
79
+ # Unique temp name per process/thread (C-HERMES-13/15): a shared
80
+ # ``.tmp`` name makes concurrent hook threads replace each other's
81
+ # files and loses learned entries.
58
82
  try:
59
83
  path.parent.mkdir(parents=True, exist_ok=True)
60
- temp_path = path.with_suffix(f"{path.suffix}.tmp")
84
+ temp_path = path.with_suffix(
85
+ f"{path.suffix}.{os.getpid()}.{threading.get_ident()}.tmp"
86
+ )
61
87
  temp_path.write_text(
62
88
  json.dumps(dict(sorted(tool_map.items())), indent=2) + "\n",
63
89
  encoding="utf-8",
@@ -99,22 +125,618 @@ def _load_recipient_allowlist() -> set:
99
125
  # Port of SDK defaults from DEFAULT_MEMORY_WRITE_TOOL_NAMES / DEFAULT_MEMORY_PATH_PATTERNS
100
126
  # so Hermes agents get the same detection surface as openclaw without extra config.
101
127
 
102
- _MEMORY_WRITE_TOOL_NAMES = frozenset({"write", "edit", "multiedit"})
128
+ # Hermes's own file tool is write_file; without it a memory write skipped the
129
+ # memory guard entirely and never established a baseline.
130
+ _MEMORY_WRITE_TOOL_NAMES = frozenset({"write", "edit", "multiedit", "write_file", "create_file"})
103
131
  _MEMORY_READ_TOOL_NAMES = frozenset({"memory_search", "memory_get"})
104
132
  _GENERIC_READ_TOOL_NAMES = frozenset({"read", "read_file"})
105
- _MEMORY_PATH_PATTERNS = (
133
+
134
+ _ZERO_WIDTH_RE = re.compile(r"[\u200b-\u200d\ufeff]")
135
+
136
+
137
+ def _normalize_tool_name(tool_name: Any) -> str:
138
+ """Canonical tool-name spelling for routing decisions.
139
+
140
+ Hosts spell the same tool `write_file`, `write-file`, `writeFile` or
141
+ ` write_file ` depending on the registry that dispatched it; routing a
142
+ write to the memory guard or the guard-state refusal must not depend
143
+ on which spelling arrived (fuzz round, family d).
144
+ """
145
+ return re.sub(r"[\s._\-]+", "", (tool_name or "").strip().lower())
146
+
147
+
148
+ def _norm_key(key: Any) -> str:
149
+ """Canonical argument-key spelling: `file_path`, `FILE_PATH` and
150
+ ` file_path` are the same key to the tool that reads them."""
151
+ return re.sub(r"[\s._\-]+", "", str(key).strip().lower())
152
+ # Matched on whole path parts, not substrings: a memory file with no baseline
153
+ # is blocked, so a substring like "memory/" must not catch src/memory/alloc.rs
154
+ # or notes/working-memory.md. Memory is: a file under one of the agent-memory
155
+ # directories below, a known memory document by exact name (Hermes's built-in
156
+ # memory tool writes memories/MEMORY.md and USER.md), or a text document inside
157
+ # a directory named memory/ or memories/. Hermes's own context files (the first
158
+ # found of .hermes.md / HERMES.md, AGENTS.override.md over AGENTS.md, Cursor
159
+ # rules) are loaded into the next session's instructions, so they are memory too.
160
+ _MEMORY_DIR_MARKERS = (
106
161
  "/.openclaw/workspace/",
107
162
  "/.openclaw/memory/",
108
163
  "/.claude/projects/",
109
- "/memory/",
110
- "memory/",
111
- "Memory.md",
112
- "DREAMS.md",
113
- "CLAUDE.md",
114
- "AGENTS.md",
164
+ )
165
+ _MEMORY_BASENAMES = frozenset(
166
+ {
167
+ "memory.md",
168
+ "dreams.md",
169
+ "claude.md",
170
+ "agents.md",
171
+ "agents.override.md",
172
+ "user.md",
173
+ ".hermes.md",
174
+ "hermes.md",
175
+ ".cursorrules",
176
+ }
177
+ )
178
+ _MEMORY_DIR_NAMES = frozenset({"memory", "memories"})
179
+ _MEMORY_DOC_SUFFIXES = frozenset({".md", ".markdown", ".txt"})
180
+
181
+ # ── Guard self-protection ────────────────────────────────────────────────────
182
+ # The guard's own mutable state lives under ~/.config/atbash (tool map,
183
+ # recipient allowlist, persisted memory-hash baseline, agent key). A write tool
184
+ # aimed at any of those paths is a self-protection attack — rewriting the
185
+ # allowlist or the baseline neuters every other check — so it is refused before
186
+ # judging, unconditionally.
187
+ _GUARD_STATE_DIR = Path("~/.config/atbash").expanduser()
188
+ _WRITE_TOOL_NAMES = frozenset(
189
+ {
190
+ "write",
191
+ "edit",
192
+ "multiedit",
193
+ "write_file",
194
+ "create_file",
195
+ "str_replace_editor",
196
+ "apply_patch",
197
+ "patch",
198
+ "fs_write",
199
+ "notebook_edit",
200
+ }
201
+ )
202
+
203
+ # Routing uses ONE write-class surface, spelling-normalised. The fuzz found
204
+ # the guard-state refusal already knew save_file/apply_patch/... as writes
205
+ # while memory classification did not, so the same call was refused at
206
+ # .hermes/.env but sailed past the memory guard at MEMORY.md — and unstripped
207
+ # or re-spelt names (` write_file`, `writeFile`, `file.write`) bypassed both.
208
+ _WRITE_TOOL_NAMES_N = frozenset(
209
+ {_normalize_tool_name(n) for n in _WRITE_TOOL_NAMES}
210
+ | {
211
+ "savefile", "appendfile", "save", "create", "append", "putfile",
212
+ "storefile", "uploadfile", "writetofile", "filewrite", "insertfile",
213
+ "touch", "tee", "filesystemwrite",
214
+ # Delete family: removing a guard file disarms the guard like
215
+ # editing it, and the refusal must fire on the first call, before
216
+ # the tool map has learned anything (security-reviewer follow-up).
217
+ "deletefile", "removefile", "delete", "remove", "unlink",
218
+ "erasefile", "erase", "truncatefile", "rm", "deletepath",
219
+ "removeitem", "deleteitem",
220
+ }
221
+ )
222
+ _RENAME_TOOL_NAMES_N = frozenset(
223
+ {"renamefile", "movefile", "rename", "move", "copyfile", "copy", "cp"}
224
+ )
225
+ # True moves (the source disappears) vs copies (the source is only read).
226
+ _MOVE_TOOL_NAMES_N = frozenset({"renamefile", "movefile", "rename", "move"})
227
+ _EDIT_TOOL_NAMES_N = frozenset({"edit", "multiedit", "strreplaceeditor"})
228
+ _APPEND_TOOL_NAMES_N = frozenset({"append", "appendfile"})
229
+ _PATCH_TOOL_NAMES_N = frozenset({"patch", "applypatch"})
230
+ _SEMANTIC_MEMORY_WRITE_TOOLS_N = frozenset(
231
+ {"memorystore", "memorysave", "memorywrite", "memoryset", "memoryupdate"}
232
+ )
233
+ _MEMORY_READ_TOOL_NAMES_N = frozenset({"memorysearch", "memoryget"})
234
+ _GENERIC_READ_TOOL_NAMES_N = frozenset(
235
+ {
236
+ "read", "readfile", "readtextfile", "fileread", "view", "viewfile",
237
+ "cat", "getfile", "openfile",
238
+ }
239
+ )
240
+
241
+ _WRITE_OPERATION_NAMES = frozenset(
242
+ {"write", "create", "update", "delete", "move", "rename", "append", "patch", "writefile"}
243
+ )
244
+
245
+
246
+ def _is_write_operation(tool_args: Dict[str, Any]) -> bool:
247
+ """Operation-discriminated tools (operation/method/action) classify by
248
+ what the call DOES; key and value are both matched spelling-normalised,
249
+ so ` write `, `write_file` and an `Operation` key are the write they
250
+ name (fuzz family d; governor follow-up @ e6a5385)."""
251
+ by_norm = _args_by_norm_key(tool_args)
252
+ for k in ("operation", "method", "action"):
253
+ v = by_norm.get(k)
254
+ if isinstance(v, str) and re.sub(r"[\s._\-]+", "", v.strip().lower()) in _WRITE_OPERATION_NAMES:
255
+ return True
256
+ return False
257
+
258
+
259
+ # Hermes reads the guard's switches (ATBASH_ENFORCE_DECISION, ATBASH_KEY_PATH)
260
+ # from <hermes home>/.env and enables plugins in <hermes home>/config.yaml, so a
261
+ # write to either disarms the guard at the next start just as surely as editing
262
+ # its state.
263
+ def _loaded_hermes_constants():
264
+ """Hermes's own hermes_constants module, only when Hermes already loaded it.
265
+
266
+ Never imported here: outside Hermes that import would find whatever
267
+ hermes_constants.py sits first on sys.path - the folder the seed command
268
+ runs in - and run it as the person doing the trust step."""
269
+ return sys.modules.get("hermes_constants")
270
+
271
+
272
+ def _hermes_homes() -> list:
273
+ """Every directory Hermes may use as its home, resolved the way Hermes does
274
+ (hermes_constants.get_hermes_home): its own answer when Hermes is loaded,
275
+ then $HERMES_HOME (the README's Docker setup mounts it at /opt/data), then
276
+ the platform default (LOCALAPPDATA/hermes on Windows), plus ~/.hermes."""
277
+ homes = []
278
+ constants = _loaded_hermes_constants()
279
+ if constants is not None:
280
+ try:
281
+ homes.append(Path(constants.get_hermes_home()))
282
+ except Exception:
283
+ pass
284
+ env_home = os.getenv("HERMES_HOME", "").strip()
285
+ if env_home:
286
+ homes.append(Path(os.path.expandvars(env_home)).expanduser())
287
+ if sys.platform == "win32":
288
+ local = os.getenv("LOCALAPPDATA", "").strip()
289
+ homes.append((Path(local) if local else Path.home() / "AppData" / "Local") / "hermes")
290
+ homes.append(Path("~/.hermes").expanduser())
291
+ unique, seen = [], set()
292
+ for h in homes:
293
+ key = os.path.normcase(str(h))
294
+ if key not in seen:
295
+ seen.add(key)
296
+ unique.append(h)
297
+ return unique
298
+
299
+
300
+ def _hermes_config_files() -> list:
301
+ """Every file Hermes reads settings or standing instructions from: each
302
+ home's .env, .op.env, config.yaml and SOUL.md (same trust class as
303
+ config.yaml in Hermes), plus the install folder's .env, which Hermes uses to
304
+ fill keys the home's .env does not set."""
305
+ files = [h / name for h in _hermes_homes() for name in (".env", ".op.env", "config.yaml", "SOUL.md")]
306
+ constants = _loaded_hermes_constants()
307
+ install_file = getattr(constants, "__file__", None) if constants is not None else None
308
+ if install_file:
309
+ files.append(Path(install_file).resolve().parent / ".env")
310
+ return files
311
+
312
+
313
+ def _hermes_env_value(name: str) -> Optional[str]:
314
+ """A setting as Hermes would see it: the process environment, else the first
315
+ Hermes home `.env` or `.op.env` that sets it (Hermes loads those files at
316
+ start and expands `$VARS` / `%VARS%`; a seed run from a plain terminal does
317
+ not). Home `.env` wins over `.op.env` in the same directory, matching
318
+ Hermes's loader (`.op.env` only fills keys the `.env` does not set)."""
319
+ value = os.getenv(name)
320
+ if value:
321
+ return os.path.expandvars(value)
322
+ for home in _hermes_homes():
323
+ for filename in (".env", ".op.env"):
324
+ env_file = home / filename
325
+ try:
326
+ lines = env_file.read_text(encoding="utf-8").splitlines()
327
+ except OSError:
328
+ continue
329
+ for line in lines:
330
+ line = line.strip()
331
+ if line.startswith("export "):
332
+ line = line[len("export "):].lstrip()
333
+ key, sep, val = line.partition("=")
334
+ if sep and key.strip() == name:
335
+ val = val.strip()
336
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in "'\"":
337
+ val = val[1:-1]
338
+ if val:
339
+ return os.path.expandvars(val)
340
+ return None
341
+
342
+
343
+ def _memory_hash_store_path() -> Path:
344
+ return Path(
345
+ _hermes_env_value("ATBASH_MEMORY_HASH_PATH")
346
+ or str(_default_tool_map_path().parent / "hermes-memory-hashes.json")
347
+ ).expanduser()
348
+
349
+
350
+ def _same_file(a: Path, b: Path) -> bool:
351
+ """True when two names reach the same file or directory.
352
+
353
+ Compares the on-disk identity (device + inode) when both exist, which
354
+ covers hard links and case-insensitive filesystems (macOS, Windows) where
355
+ realpath keeps the caller's casing. Falls back to the normalised real path
356
+ for names that do not exist yet.
357
+ """
358
+ try:
359
+ if a.exists() and b.exists():
360
+ return os.path.samefile(str(a), str(b))
361
+ except OSError:
362
+ pass
363
+ return os.path.normcase(os.path.realpath(str(a))) == os.path.normcase(os.path.realpath(str(b)))
364
+
365
+
366
+ def _guard_state_roots(extra: Iterable[Path] = ()) -> list:
367
+ roots = [_GUARD_STATE_DIR, *_hermes_config_files(), Path(__file__).resolve().parent]
368
+ # N-10: the SDK packages the guard itself imports (judge client,
369
+ # redaction, memory diff) are guard state too — rewriting them backdoors
370
+ # every check from the next process start.
371
+ for module_name in ("atbash", "atbash_core"):
372
+ module = sys.modules.get(module_name)
373
+ module_file = getattr(module, "__file__", None)
374
+ if module_file:
375
+ roots.append(Path(module_file).resolve().parent)
376
+ return [*roots, *extra]
377
+
378
+
379
+ def _expand_env_text(text: str) -> str:
380
+ """Expand the home spellings shells and tools actually use: %VAR%,
381
+ $env:VAR (PowerShell), ${VAR}, $VAR, and $HOME when the process has no
382
+ HOME variable (the usual Windows case). Unknown variables are left
383
+ untouched — an unexpanded spelling simply cannot be proven safe."""
384
+ text = re.sub(
385
+ r"(?i)\$env:([A-Za-z_][A-Za-z0-9_]*)",
386
+ lambda m: os.environ.get(m.group(1), m.group(0)),
387
+ text,
388
+ )
389
+ text = os.path.expandvars(text)
390
+ # posixpath.expandvars leaves %VAR% alone; cmd-style spellings must
391
+ # resolve the same way on every OS.
392
+ text = re.sub(
393
+ r"%([A-Za-z_][A-Za-z0-9_]*)%",
394
+ lambda m: os.environ.get(m.group(1), m.group(0)),
395
+ text,
396
+ )
397
+ if "$" in text:
398
+ text = re.sub(
399
+ r"(?i)\$(?:\{home\}|home(?![A-Za-z0-9_]))",
400
+ lambda m: str(Path.home()),
401
+ text,
402
+ )
403
+ return text
404
+
405
+
406
+ def _strip_file_uri(path: str) -> str:
407
+ """Turn a ``file:`` URI into the filesystem path it names.
408
+
409
+ ``file:///home/me/.hermes/.env`` and ``file://localhost/home/me/.hermes/.env``
410
+ are the same file as ``/home/me/.hermes/.env``. Leaving the scheme in place
411
+ made identity matching miss, so a write whose only target lived under
412
+ ``uri`` skipped the guard-state refusal.
413
+ """
414
+ text = str(path)
415
+ if not text.lower().startswith("file:"):
416
+ return text
417
+ rest = text[5:]
418
+ if rest.startswith("//"):
419
+ rest = rest[2:]
420
+ if rest.startswith("/"):
421
+ return _drop_uri_drive_slash(rest)
422
+ slash = rest.find("/")
423
+ if slash < 0:
424
+ return rest
425
+ host, path_part = rest[:slash], rest[slash:]
426
+ if host.lower() in ("", "localhost", "127.0.0.1"):
427
+ return _drop_uri_drive_slash(path_part)
428
+ return "//" + rest
429
+ if rest.startswith("/"):
430
+ return _drop_uri_drive_slash(rest)
431
+ return text
432
+
433
+
434
+ def _drop_uri_drive_slash(p: str) -> str:
435
+ """``/C:/...`` (the URI spelling of a Windows drive path) -> ``C:/...``.
436
+
437
+ ``Path.as_uri()`` spells a drive path ``file:///C:/...``; keeping the
438
+ leading slash made the canonical key ``c:/c:/...``, which never matches
439
+ the plain-path key, so the URI alias kept slipping past on Windows.
440
+ """
441
+ if len(p) >= 3 and p[0] == "/" and p[2] == ":" and p[1].isalpha():
442
+ return p[1:]
443
+ return p
444
+
445
+
446
+ def _folds_case(root: Path) -> bool:
447
+ """Whether names under `root` are opened case-insensitively.
448
+
449
+ Windows and macOS default to case-insensitive volumes. Elsewhere, probe
450
+ the nearest existing component: a case-swapped spelling that reaches the
451
+ same file means this mount folds case (a Windows/macOS bind mount, WSL
452
+ drvfs). On a case-sensitive volume `RECIPIENT-ALLOWLIST.JSON` is a
453
+ different file and must not be refused as guard state.
454
+ """
455
+ if os.name == "nt" or sys.platform == "darwin":
456
+ return True
457
+ try:
458
+ p = Path(root).expanduser().resolve()
459
+ for c in (p, *p.parents):
460
+ if not c.exists():
461
+ continue
462
+ name = c.name
463
+ if name.swapcase() == name:
464
+ continue
465
+ swapped = c.with_name(name.swapcase())
466
+ return swapped.exists() and os.path.samefile(str(c), str(swapped))
467
+ except Exception:
468
+ return True
469
+ return False
470
+
471
+
472
+ def _canonical_path_key(path: str, fold_case: bool = True) -> str:
473
+ """The identity the OS actually opens for a path spelling.
474
+
475
+ Windows opens `name.`, `name...`, `name ` and `name::$DATA` (or any
476
+ `:stream`) as `name` itself, resolves a dotted directory component
477
+ (`.hermes.`) to the plain directory, and treats `\\\\localhost\\C$\\...`,
478
+ `\\\\?\\UNC\\...` and root-relative `\\Users\\...` spellings as the local
479
+ file they name. String/realpath comparison missed all of those, and the
480
+ guard-state roots for .env/config.yaml/SOUL.md are the FILES themselves,
481
+ so each alias walked straight past the refusal (fuzz family b).
482
+ Environment spellings (%USERPROFILE%, $env:..., $HOME, ~) are expanded
483
+ first because the executing shell or tool expands them too. ``file:``
484
+ URIs are reduced to the path they name so ``uri`` aliases cannot skip
485
+ the same check.
486
+ """
487
+ text = _expand_env_text(str(path)).strip().strip('"').strip("'")
488
+ text = _strip_file_uri(text)
489
+ if text == "~" or text.startswith(("~/", "~\\")):
490
+ text = str(Path.home()) + text[1:]
491
+ text = text.replace("\\", "/")
492
+ lowered = text.lower()
493
+ if lowered.startswith("//?/unc/"):
494
+ text = "//" + text[len("//?/unc/"):]
495
+ elif lowered.startswith("//?/"):
496
+ text = text[len("//?/"):]
497
+ drive = ""
498
+ rest = text
499
+ drive_match = re.match(r"^([a-zA-Z]):(.*)$", text)
500
+ if drive_match:
501
+ drive, rest = drive_match.group(1).lower() + ":", drive_match.group(2)
502
+ elif text.startswith("//"):
503
+ segments = [s for s in text.split("/") if s]
504
+ if (
505
+ len(segments) >= 2
506
+ and segments[0].lower() in ("localhost", "127.0.0.1")
507
+ and re.fullmatch(r"[a-zA-Z]\$", segments[1])
508
+ ):
509
+ # An admin share on this machine IS the local drive.
510
+ drive, rest = segments[1][0].lower() + ":", "/".join(segments[2:])
511
+ elif len(segments) >= 2:
512
+ drive = "//" + segments[0].lower() + "/" + segments[1].lower()
513
+ rest = "/".join(segments[2:])
514
+ if not drive:
515
+ cwd = os.getcwd().replace("\\", "/")
516
+ cwd_match = re.match(r"^([a-zA-Z]):", cwd)
517
+ cwd_drive = (cwd_match.group(1).lower() + ":") if cwd_match else ""
518
+ if rest.startswith("/"):
519
+ drive = cwd_drive # root-relative: the current drive
520
+ else:
521
+ rest = (cwd[len(cwd_drive):] + "/" + rest) if cwd_drive else (cwd + "/" + rest)
522
+ drive = cwd_drive
523
+ parts = []
524
+ for part in rest.split("/"):
525
+ if part in ("", "."):
526
+ continue
527
+ if part == "..":
528
+ if parts:
529
+ parts.pop()
530
+ continue
531
+ part = _ZERO_WIDTH_RE.sub("", part).split(":", 1)[0].rstrip(". ")
532
+ if part:
533
+ parts.append(part.lower() if fold_case else part)
534
+ return (drive + "/" + "/".join(parts)) if drive else "/".join(parts)
535
+
536
+
537
+ _COMMAND_TOKEN_RE = re.compile(r'"([^"]+)"|\'([^\']+)\'|([^\s;|&<>"]+)')
538
+
539
+
540
+ def _command_path_tokens(command: str) -> list:
541
+ """Path-like tokens named in a shell command (quoted or bare)."""
542
+ tokens = []
543
+ for match in _COMMAND_TOKEN_RE.finditer(command):
544
+ token = next((g for g in match.groups() if g is not None), "")
545
+ if "/" in token or "\\" in token or ":" in token:
546
+ tokens.append(token)
547
+ return tokens
548
+
549
+
550
+ def _is_guard_state_path(path: str, extra: Iterable[Path] = ()) -> bool:
551
+ """True when `path` is, or is inside, anything the guard stands on: its state
552
+ directory, Hermes's own config, the plugin's install directory, or the extra
553
+ files (tool map, key, hash baseline) passed in."""
554
+ try:
555
+ roots = _guard_state_roots(extra)
556
+ # First the spelling-true identity: every alias above resolves to
557
+ # the same canonical key as the protected file or directory.
558
+ for root in roots:
559
+ root_text = str(Path(root).expanduser())
560
+ fold = _folds_case(Path(root_text))
561
+ key = _canonical_path_key(path, fold_case=fold)
562
+ root_key = _canonical_path_key(root_text, fold_case=fold)
563
+ if key == root_key or key.startswith(root_key + "/"):
564
+ return True
565
+ p = Path(_strip_file_uri(_expand_env_text(str(path)))).expanduser()
566
+ if not p.is_absolute():
567
+ p = Path.cwd() / p
568
+ # The target and every ancestor that exists: a new file inside a
569
+ # protected directory is caught through the directory itself.
570
+ candidates = [p, *p.parents]
571
+ for root in roots:
572
+ root = Path(root).expanduser()
573
+ for c in candidates:
574
+ if _same_file(c, root):
575
+ return True
576
+ # A hard link elsewhere to a file inside a protected directory is
577
+ # that file: compare the target against the directory's contents.
578
+ if p.exists() and p.is_file() and root.is_dir():
579
+ for i, inner in enumerate(root.rglob("*")):
580
+ if i >= 2000:
581
+ break
582
+ if inner.is_file() and _same_file(p, inner):
583
+ return True
584
+ return False
585
+ except Exception:
586
+ # Unresolvable is not "safe": the caller only asks for write-class tools.
587
+ return True
588
+
589
+
590
+ _SEEDER_DIRECT_RE = re.compile(
591
+ r"atbash[-_]hermes[-_]seed|seed_memory_baselines|hermes-memory-hashes", re.IGNORECASE
592
+ )
593
+ _SEEDER_PLUGIN_RE = re.compile(r"atbash_hermes_plugin", re.IGNORECASE)
594
+ _SEEDER_ACTION_RE = re.compile(r"\bseed\b|\brunpy\b", re.IGNORECASE)
595
+ _B64_TOKEN_RE = re.compile(r"[A-Za-z0-9+/]{16,}={0,2}")
596
+ _PATCH_TARGET_RE = re.compile(
597
+ r"(?im)(?:\*\*\*\s+(?:Add|Update|Delete|Rename)\s+File:\s+|"
598
+ r"\*\*\*\s+Move to:\s+|"
599
+ r"^(?:---|\+\+\+)\s+(?:[ab]/)?)"
600
+ r"([^\s]+)"
115
601
  )
116
602
 
117
603
 
604
+ def _seeder_text(text: str) -> bool:
605
+ # Quotes and shell variable tricks only split the words apart; compare
606
+ # without them, and also inside any base64 token the command decodes.
607
+ flat = re.sub(r"[\"'`]", "", text)
608
+ candidates = [flat]
609
+ for token in _B64_TOKEN_RE.findall(text):
610
+ # Decode up to two layers, standard and URL-safe alphabets, and
611
+ # UTF-16LE (powershell -EncodedCommand) — one layer of plain base64
612
+ # was the only encoding the seeder check could see through (N-11).
613
+ current = [token]
614
+ for _ in range(2):
615
+ decoded_texts = []
616
+ for candidate_token in current:
617
+ padded = candidate_token + "=" * (-len(candidate_token) % 4)
618
+ for decoder in (base64.b64decode, base64.urlsafe_b64decode):
619
+ try:
620
+ raw = decoder(padded, validate=True)
621
+ except Exception:
622
+ continue
623
+ decoded_texts.append(raw.decode("utf-8", "ignore"))
624
+ decoded_texts.append(raw.decode("utf-16-le", "ignore"))
625
+ candidates.extend(decoded_texts[-2:])
626
+ current = _B64_TOKEN_RE.findall(" ".join(decoded_texts))
627
+ if not current:
628
+ break
629
+ for c in candidates:
630
+ if _SEEDER_DIRECT_RE.search(c):
631
+ return True
632
+ if _SEEDER_PLUGIN_RE.search(c) and _SEEDER_ACTION_RE.search(c):
633
+ return True
634
+ return False
635
+
636
+
637
+ def _invokes_seeder(tool_args: Any) -> bool:
638
+ """True when any argument runs (or rewrites what) the baseline seeder (writes)
639
+ - trusting a memory file is a human step, so the agent may never do it for
640
+ itself. Best effort: a determined shell can still rewrite the plain-JSON
641
+ baseline file, which the README lists as a known limit."""
642
+ if isinstance(tool_args, str):
643
+ return _seeder_text(tool_args)
644
+ if isinstance(tool_args, dict):
645
+ return any(_invokes_seeder(v) for v in tool_args.values())
646
+ if isinstance(tool_args, (list, tuple)):
647
+ return any(_invokes_seeder(v) for v in tool_args)
648
+ return False
649
+
650
+
651
+ def _paths_named_in(text: str) -> list:
652
+ """Filenames a patch body or a paths-list argument may name.
653
+
654
+ Includes a single-line path as-is (a `paths` entry) and V4A / unified-diff
655
+ target headers, so a relative `SOUL.md` is checked the same way a `path`
656
+ argument is.
657
+ """
658
+ found = []
659
+ stripped = text.strip().strip("\"'`")
660
+ if stripped and "\n" not in stripped:
661
+ found.append(stripped)
662
+ for m in _PATCH_TARGET_RE.finditer(text):
663
+ found.append(m.group(1).strip("\"'`").rstrip(":"))
664
+ return found
665
+
666
+
667
+ def _patch_bodies(value: Any) -> list:
668
+ """Every argument string that is a V4A / unified-diff body (it carries a
669
+ target header), at any nesting depth."""
670
+ if isinstance(value, str):
671
+ return [value] if _PATCH_TARGET_RE.search(value) else []
672
+ if isinstance(value, dict):
673
+ return [b for item in value.values() for b in _patch_bodies(item)]
674
+ if isinstance(value, (list, tuple)):
675
+ return [b for item in value for b in _patch_bodies(item)]
676
+ return []
677
+
678
+
679
+ def _patch_header_targets(bodies: Iterable[str]) -> list:
680
+ """Files the patch headers name. A bare mention elsewhere is not a target:
681
+ only a header decides where the patch lands."""
682
+ return [
683
+ named
684
+ for body in bodies
685
+ for m in _PATCH_TARGET_RE.finditer(body)
686
+ for named in (m.group(1).strip("\"'`").rstrip(":"),)
687
+ if named and named != "/dev/null"
688
+ ]
689
+
690
+
691
+ def _mentions_guard_state(value: Any, extra: Iterable[Path] = ()) -> bool:
692
+ """For write tools whose target is not a single path argument (a patch body,
693
+ a `paths` list), refuse when any string argument names a protected path.
694
+
695
+ Relative names such as `SOUL.md` or `.env` are resolved against the process
696
+ cwd the same way `_is_guard_state_path` does, so a patch applied after `cd`
697
+ into a Hermes home still cannot rewrite guard files.
698
+ """
699
+ needles = {
700
+ str(Path(r).expanduser()).replace("\\", "/").lower().rstrip("/") for r in _guard_state_roots(extra)
701
+ }
702
+ needles |= {".config/atbash", ".hermes/.env", ".hermes/.op.env", ".hermes/config.yaml", ".hermes/soul.md"}
703
+ # A needle only counts as a whole path component: ".config/atbash-cli" and
704
+ # ".hermes/.envrc" are other files.
705
+ patterns = [re.compile(re.escape(n) + r"(?![\w.-])") for n in needles if n]
706
+
707
+ def normalise(text: str) -> str:
708
+ t = _ZERO_WIDTH_RE.sub("", _expand_env_text(text)).replace("\\", "/").lower()
709
+ t = t.replace("//?/unc/", "//").replace("//?/", "/")
710
+ # An admin share on this machine names the local drive itself.
711
+ t = re.sub(r"//(?:localhost|127\.0\.0\.1)/([a-z])\$/", r"\1:/", t)
712
+ t = re.sub(r"/(?:\./)+", "/", t) # a/./b -> a/b
713
+ t = re.sub(r"/{2,}", "/", t) # a//b -> a/b
714
+ prev = None
715
+ while prev != t: # a/x/../b -> a/b
716
+ prev = t
717
+ t = re.sub(r"/(?!\.\.?/)[^/\s\"'`]+/\.\./", "/", t)
718
+ # Win32 opens `name.`, `name...` and `name::$DATA` as `name` itself;
719
+ # without this the dotted-directory spelling `.config/atbash./...`
720
+ # slipped past the needle match (fuzz family b).
721
+ t = re.sub(r":\$data(?=/|$|[\s\"'\)\];,])", "", t)
722
+ t = re.sub(r"\.+(?=/|$|[\s\"'\)\];,])", "", t)
723
+ return t
724
+
725
+ def walk(v: Any) -> bool:
726
+ if isinstance(v, str):
727
+ t = normalise(v)
728
+ if any(p.search(t) for p in patterns):
729
+ return True
730
+ return any(_is_guard_state_path(p, extra) for p in _paths_named_in(v))
731
+ if isinstance(v, dict):
732
+ return any(walk(x) for x in v.values())
733
+ if isinstance(v, (list, tuple)):
734
+ return any(walk(x) for x in v)
735
+ return False
736
+
737
+ return walk(value)
738
+
739
+
118
740
  # Copied verbatim from @atbash/sdk so the judge LLM receives the same
119
741
  # memory-scan system prompt as openclaw — the SCORE prefix it returns is
120
742
  # required for quality-gated rollback even though we don't do chain commits yet.
@@ -179,15 +801,27 @@ _MEMORY_SCAN_CONTEXT = (
179
801
  )
180
802
 
181
803
 
804
+ def _neutralize_scan_delimiters(text: Any) -> str:
805
+ """Keep wrapper tokens inside untrusted fields from closing the scan region."""
806
+ return str(text).replace(
807
+ "<<<END_UNTRUSTED_MEMORY_CONTENT>>>", "<<<END_UNTRUSTED_MEMORY_CONTENT (in content)>>>"
808
+ ).replace(
809
+ "<<<UNTRUSTED_MEMORY_CONTENT>>>", "<<<UNTRUSTED_MEMORY_CONTENT (in content)>>>"
810
+ )
811
+
812
+
182
813
  def _format_entry_for_scan(entry: Dict[str, Any]) -> str:
183
814
  """Wrap a memory entry in <<<UNTRUSTED_MEMORY_CONTENT>>> delimiters for scanning."""
815
+ # N-5: untrusted fields must never be able to close the wrapper early —
816
+ # a literal END delimiter in the value, key, or source would put attacker
817
+ # text outside the untrusted region for the judge.
184
818
  parts = [
185
819
  "<<<UNTRUSTED_MEMORY_CONTENT>>>",
186
- f"MEMORY KEY: {entry['key']}",
187
- f"MEMORY VALUE: {entry['value']}",
820
+ f"MEMORY KEY: {_neutralize_scan_delimiters(entry['key'])}",
821
+ f"MEMORY VALUE: {_neutralize_scan_delimiters(entry['value'])}",
188
822
  ]
189
823
  if entry.get("source"):
190
- parts.append(f"SOURCE: {entry['source']}")
824
+ parts.append(f"SOURCE: {_neutralize_scan_delimiters(entry['source'])}")
191
825
  parts.append("<<<END_UNTRUSTED_MEMORY_CONTENT>>>")
192
826
  return "\n".join(parts)
193
827
 
@@ -196,6 +830,38 @@ def _sha256_hex(content: str) -> str:
196
830
  return hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
197
831
 
198
832
 
833
+ def _memory_key(path: str) -> str:
834
+ """One key per file, whatever spelling the tool call used (./AGENTS.md,
835
+ an absolute path, a different case on a case-insensitive filesystem)."""
836
+ try:
837
+ return os.path.normcase(os.path.realpath(str(Path(path).expanduser())))
838
+ except Exception:
839
+ return path
840
+
841
+
842
+ def seed_memory_baselines(paths: Iterable[str], store: "Optional[_MemoryHashStore]" = None) -> list:
843
+ """Record the current SHA-256 of each file as its trusted baseline.
844
+
845
+ The human step behind "no baseline = untrusted": run it yourself (outside
846
+ the agent) after reviewing the files, e.g.
847
+ python -m atbash_hermes_plugin seed AGENTS.md memory/notes.md
848
+ Nothing is sent anywhere; the hashes go to the local baseline file.
849
+ """
850
+ # Read every file first: one bad name in the list trusts nothing, rather
851
+ # than trusting the files before it and then failing.
852
+ contents = []
853
+ for raw in paths:
854
+ p = Path(raw).expanduser()
855
+ contents.append((p, p.read_text(encoding="utf-8", errors="replace")))
856
+ if store is None:
857
+ store = _MemoryHashStore(_memory_hash_store_path())
858
+ seeded = []
859
+ for p, content in contents:
860
+ store.set(str(p), _sha256_hex(content))
861
+ seeded.append(str(p))
862
+ return seeded
863
+
864
+
199
865
  class _MemoryHashStore:
200
866
  """Persistent hash store for memory files — survives process restarts.
201
867
 
@@ -223,21 +889,31 @@ class _MemoryHashStore:
223
889
 
224
890
  def get(self, file_path: str) -> Optional[str]:
225
891
  self._ensure_loaded()
226
- return self._data.get(file_path)
892
+ # Canonical key first; the raw key keeps baselines written by older
893
+ # versions (keyed by whatever path string the tool call carried).
894
+ return self._data.get(_memory_key(file_path), self._data.get(file_path))
227
895
 
228
896
  def set(self, file_path: str, sha256: str) -> None:
229
897
  self._ensure_loaded()
230
- self._data[file_path] = sha256
898
+ self._data[_memory_key(file_path)] = sha256
231
899
  self._persist()
232
900
 
233
901
  def items(self):
234
902
  self._ensure_loaded()
235
903
  return list(self._data.items())
236
904
 
905
+ def remove(self, file_path: str) -> None:
906
+ self._ensure_loaded()
907
+ self._data.pop(_memory_key(file_path), None)
908
+ self._data.pop(file_path, None)
909
+ self._persist()
910
+
237
911
  def _persist(self) -> None:
238
912
  try:
239
913
  self._path.parent.mkdir(parents=True, exist_ok=True)
240
- tmp = self._path.with_suffix(self._path.suffix + ".tmp")
914
+ tmp = self._path.with_suffix(
915
+ f"{self._path.suffix}.{os.getpid()}.{threading.get_ident()}.tmp"
916
+ )
241
917
  tmp.write_text(
242
918
  json.dumps(dict(sorted(self._data.items())), indent=2) + "\n",
243
919
  encoding="utf-8",
@@ -247,22 +923,134 @@ class _MemoryHashStore:
247
923
  logger.warning("Atbash memory hash store write error path=%s err=%s", self._path, e)
248
924
 
249
925
 
926
+ def _path_parts(path: str) -> list:
927
+ """Lower-cased path parts, spelled the way the filesystem resolves them.
928
+
929
+ On Windows `AGENTS.md.`, `AGENTS.md ` and `AGENTS.md::$DATA` open AGENTS.md
930
+ itself, so trailing dots and spaces and any `:stream` suffix are dropped
931
+ from each part. The drive (`c:`, also drive-relative `c:AGENTS.md`, or a UNC
932
+ share) is split off first so its colon is never read as a stream. `.` parts
933
+ are dropped and `..` removes the part before it, everywhere."""
934
+ text = path.replace("\\", "/").lower()
935
+ drive = ""
936
+ if os.name == "nt":
937
+ drive, text = ntpath.splitdrive(text)
938
+ parts = [drive] if drive else []
939
+ floor = len(parts)
940
+ for part in text.split("/"):
941
+ if part == "..":
942
+ if len(parts) > floor:
943
+ parts.pop()
944
+ continue
945
+ if os.name == "nt":
946
+ part = part.split(":", 1)[0].rstrip(". ")
947
+ if part and part != ".":
948
+ parts.append(part)
949
+ return parts
950
+
951
+
250
952
  def _matches_memory_path(path: str) -> bool:
251
- p = path.replace("\\", "/").lower()
252
- return any(pat.lower() in p for pat in _MEMORY_PATH_PATTERNS)
953
+ parts = _path_parts(path)
954
+ if not parts:
955
+ return False
956
+ joined = "/" + "/".join(parts) + "/"
957
+ if any(marker in joined for marker in _MEMORY_DIR_MARKERS):
958
+ return True
959
+ name = parts[-1]
960
+ if name in _MEMORY_BASENAMES:
961
+ return True
962
+ if len(parts) >= 3 and parts[-3:-1] == [".cursor", "rules"] and name.endswith(".mdc"):
963
+ return True
964
+ for index, part in enumerate(parts[:-1]):
965
+ if part not in _MEMORY_DIR_NAMES:
966
+ continue
967
+ # Inside Hermes's own memory dirs any state file counts (state.json
968
+ # is memory too, N-7); a generic project memory/ folder only counts
969
+ # for document suffixes, so ordinary code there stays unclassified.
970
+ if ".hermes" in parts[:index] or Path(name).suffix in _MEMORY_DOC_SUFFIXES:
971
+ return True
972
+ # N-6: a symlink/junction alias must classify by where it lands.
973
+ try:
974
+ resolved = os.path.realpath(str(Path(path).expanduser()))
975
+ except Exception:
976
+ return False
977
+ if resolved and resolved.replace("\\", "/").lower() != path.replace("\\", "/").lower():
978
+ resolved_parts = _path_parts(resolved)
979
+ resolved_name = resolved_parts[-1] if resolved_parts else ""
980
+ if resolved_name in _MEMORY_BASENAMES:
981
+ return True
982
+ return any(p in _MEMORY_DIR_NAMES for p in resolved_parts[:-1]) and (
983
+ ".hermes" in resolved_parts or Path(resolved_name).suffix in _MEMORY_DOC_SUFFIXES
984
+ )
985
+ return False
986
+
987
+
988
+ def _args_by_norm_key(args: Dict[str, Any]) -> Dict[str, Any]:
989
+ by_norm: Dict[str, Any] = {}
990
+ for k, v in args.items():
991
+ by_norm.setdefault(_norm_key(k), v)
992
+ return by_norm
993
+
994
+
995
+ _PATH_KEY_PRIORITY = (
996
+ "file_path", "path", "filepath", "filename", "file_name", "pathname",
997
+ "path_name", "uri", "location", "target", "file", "destination",
998
+ "dest", "destination_path", "dest_path", "new_path", "to_path",
999
+ )
253
1000
 
254
1001
 
255
1002
  def _pick_path(args: Dict[str, Any]) -> Optional[str]:
256
- # Key order matches SDK pickPath / extractPath — includes "filePath", "target", "file"
257
- for k in ("file_path", "path", "filePath", "filename", "target", "file"):
258
- v = args.get(k)
1003
+ # Key order matches SDK pickPath / extractPath — includes "filePath",
1004
+ # "target", "file". Keys are matched spelling-normalised (`FILE_PATH`,
1005
+ # ` file_path`) and the list covers the aliases real tools use
1006
+ # (filepath, uri, dest, ...): a closed list let the same write slip
1007
+ # past routing under a synonym key (fuzz family d).
1008
+ by_norm = _args_by_norm_key(args)
1009
+ for k in _PATH_KEY_PRIORITY:
1010
+ v = by_norm.get(_norm_key(k))
259
1011
  if isinstance(v, str) and v.strip():
260
1012
  return v.strip()
261
1013
  return None
262
1014
 
263
1015
 
1016
+ def _pick_destination(args: Dict[str, Any]) -> Optional[str]:
1017
+ by_norm = _args_by_norm_key(args)
1018
+ for k in ("destination", "dest_path", "new_path", "target", "to_path", "dest", "destination_path"):
1019
+ v = by_norm.get(_norm_key(k))
1020
+ if isinstance(v, str) and v.strip():
1021
+ return v.strip()
1022
+ return None
1023
+
1024
+
1025
+ _SOURCE_KEY_PRIORITY = (
1026
+ "source", "source_path", "sourcepath", "src", "from", "from_path",
1027
+ "old_path", "oldpath", "origin", "original_path",
1028
+ )
1029
+
1030
+
1031
+ def _pick_source(args: Dict[str, Any]) -> Optional[str]:
1032
+ """The rename/move source, under its own spellings. _pick_path has no
1033
+ source key (it would return the destination, which it also covers),
1034
+ so either-end checks and rename content reads must ask here. Keys are
1035
+ disjoint from the destination set on purpose: a key already consumed
1036
+ as the destination (`target`) is never re-read as the source."""
1037
+ by_norm = _args_by_norm_key(args)
1038
+ for k in _SOURCE_KEY_PRIORITY:
1039
+ v = by_norm.get(_norm_key(k))
1040
+ if isinstance(v, str) and v.strip():
1041
+ return v.strip()
1042
+ return None
1043
+
1044
+
1045
+ _CONTENT_KEY_PRIORITY = (
1046
+ "content", "newText", "new_string", "new_str", "newStr", "replacement",
1047
+ "value", "text", "body", "data", "contents", "payload", "new_content",
1048
+ "input", "delta", "body_text", "text_content",
1049
+ )
1050
+
1051
+
264
1052
  def _pick_write_content(tool_name: str, args: Dict[str, Any]) -> Optional[str]:
265
- t = tool_name.lower()
1053
+ t = _normalize_tool_name(tool_name)
266
1054
  # SDK EDIT_NEW_CONTENT_KEYS order: newText first, then snake_case variants.
267
1055
  _EDIT_KEYS = ("newText", "new_string", "new_str", "newStr", "replacement")
268
1056
  # Check edits array first for any tool — SDK checks edits before direct keys.
@@ -271,49 +1059,150 @@ def _pick_write_content(tool_name: str, args: Dict[str, Any]) -> Optional[str]:
271
1059
  parts: list[str] = []
272
1060
  for e in edits:
273
1061
  if isinstance(e, dict):
1062
+ e_by_norm = _args_by_norm_key(e)
274
1063
  for k in _EDIT_KEYS:
275
- vv = e.get(k)
1064
+ vv = e_by_norm.get(_norm_key(k))
276
1065
  if isinstance(vv, str) and vv.strip():
277
1066
  parts.append(vv)
278
1067
  break
279
1068
  if parts:
280
1069
  return "\n".join(parts)
281
- if t in ("write", "multiedit") and isinstance(args.get("content"), str) and args["content"].strip():
282
- return args["content"]
283
- if t == "edit":
1070
+ by_norm = _args_by_norm_key(args)
1071
+ if t in _EDIT_TOOL_NAMES_N:
284
1072
  for k in _EDIT_KEYS:
285
- v = args.get(k)
1073
+ v = by_norm.get(_norm_key(k))
286
1074
  if isinstance(v, str) and v.strip():
287
1075
  return v
288
- # Fallback mirrors SDK: content, then EDIT_NEW_CONTENT_KEYS, then value/text.
289
- for k in ("content", "newText", "new_string", "new_str", "newStr", "replacement", "value", "text", "body"):
290
- v = args.get(k)
1076
+ # Fallback mirrors SDK: content, then EDIT_NEW_CONTENT_KEYS, then
1077
+ # value/text — extended with the aliases real tools use (data, payload,
1078
+ # input, ...), which a closed list let slip past the memory scan.
1079
+ for k in _CONTENT_KEY_PRIORITY:
1080
+ v = by_norm.get(_norm_key(k))
291
1081
  if isinstance(v, str) and v.strip():
292
1082
  return v
293
1083
  return None
294
1084
 
295
1085
 
1086
+ _SEMANTIC_MEMORY_WRITE_TOOLS = frozenset(
1087
+ {"memory_store", "memory_save", "memory_write", "memory_set", "memory_update"}
1088
+ )
1089
+
1090
+
296
1091
  def _classify_memory_write(
297
1092
  tool_name: str, tool_args: Dict[str, Any]
298
1093
  ) -> Optional[Dict[str, Any]]:
299
1094
  """Return {key, value, source} if this writes to a memory-shaped path, else None."""
300
- if tool_name.lower() not in _MEMORY_WRITE_TOOL_NAMES:
1095
+ t = _normalize_tool_name(tool_name)
1096
+ # N-2: semantic memory tools name no file at all — their key IS the
1097
+ # memory slot, and the value must be judged/scanned like any write.
1098
+ if t in _SEMANTIC_MEMORY_WRITE_TOOLS_N:
1099
+ by_norm = _args_by_norm_key(tool_args)
1100
+ key = by_norm.get(_norm_key("key"))
1101
+ # The same content aliases file writes use (payload/data/input and
1102
+ # cased spellings), not a narrower second list (reviewer follow-up).
1103
+ value = next(
1104
+ (
1105
+ by_norm[_norm_key(k)]
1106
+ for k in dict.fromkeys(
1107
+ ("value", "content", "text", "memory", *_CONTENT_KEY_PRIORITY)
1108
+ )
1109
+ if isinstance(by_norm.get(_norm_key(k)), str)
1110
+ ),
1111
+ None,
1112
+ )
1113
+ if isinstance(key, str) and key.strip() and value is not None:
1114
+ return {"key": key.strip(), "value": value, "source": f"plugin:hermes:{tool_name}"}
1115
+ return None
1116
+ if t in _RENAME_TOOL_NAMES_N:
1117
+ # A move lands at its destination: poison does not become clean by
1118
+ # being written elsewhere first and renamed into memory (N-2).
1119
+ destination = _pick_destination(tool_args)
1120
+ # The content read must follow the SOURCE spelling; _pick_path
1121
+ # alone returns the destination for {source, destination} calls
1122
+ # and the wrong file (or nothing) would be scanned.
1123
+ source = _pick_source(tool_args) or _pick_path(tool_args)
1124
+ if not destination or not _matches_memory_path(destination):
1125
+ return None
1126
+ try:
1127
+ value = Path(source).expanduser().read_text(encoding="utf-8", errors="replace") if source else ""
1128
+ except Exception:
1129
+ value = ""
1130
+ return {"key": destination, "value": value, "source": f"plugin:hermes:{tool_name}"}
1131
+ # Fuzz family d: memory routing uses the same write-class surface as
1132
+ # the guard-state refusal (one normalised set plus operation semantics),
1133
+ # not a narrower second list — save_file/apply_patch/fs_write/... were
1134
+ # refused at guard paths but reached MEMORY.md unjudged-by-memory.
1135
+ if t not in _WRITE_TOOL_NAMES_N and not _is_write_operation(tool_args):
301
1136
  return None
302
1137
  path = _pick_path(tool_args)
1138
+ is_patch = t in _PATCH_TOOL_NAMES_N
1139
+ bodies = _patch_bodies(tool_args) if is_patch else []
1140
+ targets = _patch_header_targets(bodies)
1141
+ # Patch tools name their real targets in the body headers; a patch of
1142
+ # MEMORY.md with no (or a harmless) path argument must still reach the
1143
+ # memory judge, and one that also lands elsewhere must still reach the
1144
+ # regular judge for the rest.
1145
+ other_targets = [x for x in targets if not _matches_memory_path(x)]
303
1146
  if not path or not _matches_memory_path(path):
304
- return None
1147
+ memory_targets = [x for x in targets if _matches_memory_path(x)]
1148
+ if not memory_targets:
1149
+ return None
1150
+ if path:
1151
+ other_targets.append(path)
1152
+ path = memory_targets[0]
305
1153
  content = _pick_write_content(tool_name, tool_args)
306
- if not content:
307
- return None
308
- return {"key": path, "value": content, "source": f"plugin:hermes:{tool_name}"}
1154
+ if is_patch and bodies:
1155
+ # The scanned value must include the patch that runs: a `content`
1156
+ # decoy is picked ahead of the `input` body by the alias priority.
1157
+ parts = [content] if content is not None else []
1158
+ parts.extend(b for b in bodies if b not in parts)
1159
+ content = "\n".join(parts)
1160
+ entry_extra = {"judge_rest": True} if other_targets else {}
1161
+ if content is None:
1162
+ # N-7: truncating a memory file with an empty write is still a write.
1163
+ # Honour the same content aliases the picker uses (`payload`, `data`,
1164
+ # `input`, cased/padded names); only the exact `content` key used to
1165
+ # be accepted, so empty alias writes skipped the memory scanner.
1166
+ by_norm = _args_by_norm_key(tool_args)
1167
+ empty = next(
1168
+ (
1169
+ by_norm[_norm_key(k)]
1170
+ for k in _CONTENT_KEY_PRIORITY
1171
+ if isinstance(by_norm.get(_norm_key(k)), str)
1172
+ ),
1173
+ None,
1174
+ )
1175
+ if empty is None:
1176
+ # An edits array whose new text is empty truncates just the
1177
+ # same; _pick_write_content skips empty parts, so look for the
1178
+ # keys directly (reviewer follow-up).
1179
+ edits = tool_args.get("edits")
1180
+ if isinstance(edits, list):
1181
+ parts = []
1182
+ found = False
1183
+ for e in edits:
1184
+ if isinstance(e, dict):
1185
+ e_by_norm = _args_by_norm_key(e)
1186
+ for k in ("newText", "new_string", "new_str", "newStr", "replacement"):
1187
+ vv = e_by_norm.get(_norm_key(k))
1188
+ if isinstance(vv, str):
1189
+ parts.append(vv)
1190
+ found = True
1191
+ break
1192
+ if found:
1193
+ empty = "\n".join(parts)
1194
+ if empty is None:
1195
+ return None
1196
+ content = empty
1197
+ return {"key": path, "value": content, "source": f"plugin:hermes:{tool_name}", **entry_extra}
309
1198
 
310
1199
 
311
1200
  def _classify_memory_read(tool_name: str, tool_args: Dict[str, Any]) -> bool:
312
1201
  """Return True if this reads from a memory-shaped path."""
313
- t = tool_name.lower()
314
- if t in _MEMORY_READ_TOOL_NAMES:
1202
+ t = _normalize_tool_name(tool_name)
1203
+ if t in _MEMORY_READ_TOOL_NAMES_N:
315
1204
  return True
316
- if t in _GENERIC_READ_TOOL_NAMES:
1205
+ if t in _GENERIC_READ_TOOL_NAMES_N:
317
1206
  path = _pick_path(tool_args)
318
1207
  return bool(path and _matches_memory_path(path))
319
1208
  return False
@@ -340,11 +1229,87 @@ def _resolve_asset(token: str) -> str:
340
1229
  _STATEMENT_SEP_RE = re.compile(r"[;&|<>$`\r\n]")
341
1230
 
342
1231
  def _flag_values(command: str, name: str) -> list:
1232
+ # CLI spellings vary: case (`--Amount`), a single dash (`-amount`),
1233
+ # `:`/`=` separators, a quote glued to the value, zero-width characters
1234
+ # from pasted text. A flag this parser cannot see is an amount or a
1235
+ # beneficiary the canonicalizer will swear was never given (fuzz c).
1236
+ # Zero-width characters become separators: pasted text carries them
1237
+ # exactly where the eye sees a space (`--amount<ZWSP>100`).
1238
+ text = _ZERO_WIDTH_RE.sub(" ", command)
343
1239
  return [
344
1240
  value.strip("'\"")
345
- for value in re.findall(rf"--{re.escape(name)}[= ]+(\S+)", command)
1241
+ for value in re.findall(
1242
+ rf"(?<![A-Za-z0-9_\-])--?{re.escape(name)}(?![A-Za-z0-9_\-])[\s=:\"']*([^\s;|&<>]+)",
1243
+ text,
1244
+ re.IGNORECASE,
1245
+ )
346
1246
  ]
347
1247
 
1248
+
1249
+ # Every flag that names who receives the value. The guard's own code judges
1250
+ # --spender as the beneficiary of an approval, so spender — and the other
1251
+ # beneficiary aliases real CLIs use — count for ambiguity exactly like
1252
+ # --to/--recipient do (fuzz family c).
1253
+ _RECIPIENT_FLAG_NAMES = (
1254
+ "to", "recipient", "spender", "destination", "beneficiary", "payee", "target",
1255
+ )
1256
+ # Every flag that states how much moves, besides --max-usd (kept separate:
1257
+ # it caps rather than states the amount).
1258
+ _AMOUNT_FLAG_NAMES = (
1259
+ "amount", "value", "quantity", "usd", "amount-usd", "amt", "sum", "total",
1260
+ )
1261
+
1262
+
1263
+ def _all_flag_values(command: str, names) -> list:
1264
+ return [value for name in names for value in _flag_values(command, name)]
1265
+
1266
+
1267
+ def _first_flag_value(command: str, names) -> Optional[str]:
1268
+ for name in names:
1269
+ values = _flag_values(command, name)
1270
+ if values:
1271
+ return values[0]
1272
+ return None
1273
+
1274
+
1275
+ def _noncanonical_flag_separator(command: str, names) -> bool:
1276
+ """A financial flag written with a separator a CLI may not read —
1277
+ `--amount:100`, `--amount"100`, `--amount<ZWSP>100`. _flag_values reads
1278
+ the value anyway (so the amount is SEEN), but asserting it would claim
1279
+ the executor read it too; many parsers treat the whole token as an
1280
+ unknown flag instead. The reading is therefore withheld (fuzz c)."""
1281
+ for name in names:
1282
+ for match in re.finditer(
1283
+ rf"(?<![A-Za-z0-9_\-])--?{re.escape(name)}(?![A-Za-z0-9_\-])([\s=:\"'\u200b-\u200d\ufeff]*)",
1284
+ command,
1285
+ re.IGNORECASE,
1286
+ ):
1287
+ if any(c not in " \t=" for c in match.group(1)):
1288
+ return True
1289
+ return False
1290
+
1291
+
1292
+ def _positional_amount(command: str) -> Optional[str]:
1293
+ """A bare numeric token left after flags, addresses and separators are
1294
+ removed: `transfer --to 0x... 999999` states an amount positionally,
1295
+ and reporting amount=None next to an allowlisted recipient swears the
1296
+ command named no amount at all (fuzz family c)."""
1297
+ text = _ZERO_WIDTH_RE.sub(" ", command)
1298
+ # Remove each flag together with its value on the RAW text (the value
1299
+ # may be the address _flag_values would read); a value token starting
1300
+ # with `-` is the next flag, never a value. Whatever number survives
1301
+ # was stated positionally.
1302
+ text = re.sub(
1303
+ r"(?<![A-Za-z0-9_\-])--?[A-Za-z][A-Za-z0-9_\-]*(?:[\s=:\"']+[^\s;|&<>\-][^\s;|&<>]*)?",
1304
+ " ",
1305
+ text,
1306
+ )
1307
+ for token in text.split():
1308
+ token = token.strip("'\"(),")
1309
+ if _numeric_amount(token) is not None:
1310
+ return token
1311
+ return None
1312
+
348
1313
  def _numeric_amount(value: Optional[str]) -> Optional[str]:
349
1314
  """Return ``value`` only if it is a plain ASCII decimal, else ``None``.
350
1315
 
@@ -404,7 +1369,7 @@ def _strict_reading(
404
1369
  True and none of these facts bound what actually runs — which is why they
405
1370
  are named "…_named" and why nothing here may be read as permission.
406
1371
  """
407
- recipients = _flag_values(command, "to") + _flag_values(command, "recipient")
1372
+ recipients = _all_flag_values(command, _RECIPIENT_FLAG_NAMES)
408
1373
  lowered_recipients = {r.lower() for r in recipients}
409
1374
  tokens = _flag_values(command, "token")
410
1375
  addresses = [
@@ -414,7 +1379,7 @@ def _strict_reading(
414
1379
  ]
415
1380
  assets = sorted({_resolve_asset(t) for t in tokens + addresses}) or ["ETH"]
416
1381
 
417
- raw_amounts = _flag_values(command, "amount") + _flag_values(command, "max-usd")
1382
+ raw_amounts = _all_flag_values(command, _AMOUNT_FLAG_NAMES) + _flag_values(command, "max-usd")
418
1383
  parsed = [_numeric_amount(v) for v in raw_amounts]
419
1384
  if any(p is None for p in parsed):
420
1385
  max_amount: Optional[str] = "unparsable"
@@ -438,16 +1403,29 @@ def _assert_sdk_capabilities(
438
1403
  atbash_class: Any,
439
1404
  tool_call_input_class: Any,
440
1405
  ) -> None:
441
- guard_api_version = getattr(sdk, "GUARD_API_VERSION", 0)
442
- if not isinstance(guard_api_version, int) or guard_api_version < 1:
1406
+ # Real SDK contract (verified against shipped PyPI artifacts: installed
1407
+ # 0.7.1.dev0 and the 0.10.0 wheel export no GUARD_API_VERSION; the
1408
+ # in-repo SDK source tree declares one internally, which is tolerated).
1409
+ # Demanding the export bricked the guard on every shipped install, so
1410
+ # what the guard actually relies on is checked instead: the exact
1411
+ # from_config parameters it passes, audit_tool_call, judge_action, and
1412
+ # ToolCallInput(resolved). A GUARD_API_VERSION that IS present but
1413
+ # below 1 is still refused — an explicit old marker is evidence of an
1414
+ # old SDK, not a missing optional attribute.
1415
+ guard_api_version = getattr(sdk, "GUARD_API_VERSION", None)
1416
+ if guard_api_version is not None and (
1417
+ not isinstance(guard_api_version, int) or guard_api_version < 1
1418
+ ):
443
1419
  raise RuntimeError(
444
- "atbash-sdk does not expose hardened guard API version 1; "
445
- "install atbash-sdk==0.2.0"
1420
+ "atbash-sdk exposes a guard API version below 1; "
1421
+ "install atbash-sdk>=0.12.0.dev0,<1.0.0"
446
1422
  )
447
1423
  if not callable(getattr(atbash_class, "from_config", None)):
448
1424
  raise RuntimeError("atbash-sdk is missing Atbash.from_config")
449
1425
  if not callable(getattr(atbash_class, "audit_tool_call", None)):
450
1426
  raise RuntimeError("atbash-sdk is missing Atbash.audit_tool_call")
1427
+ if not callable(getattr(atbash_class, "judge_action", None)):
1428
+ raise RuntimeError("atbash-sdk is missing Atbash.judge_action")
451
1429
  try:
452
1430
  from_config_parameters = inspect.signature(
453
1431
  atbash_class.from_config
@@ -455,7 +1433,7 @@ def _assert_sdk_capabilities(
455
1433
  parameters = inspect.signature(tool_call_input_class).parameters
456
1434
  except (TypeError, ValueError) as error:
457
1435
  raise RuntimeError("cannot inspect atbash-sdk guard APIs") from error
458
- for required_parameter in ("judge", "org_name", "fail_closed"):
1436
+ for required_parameter in ("judge", "org_name", "fail_closed", "agent_key", "key_path", "timeout"):
459
1437
  if required_parameter not in from_config_parameters:
460
1438
  raise RuntimeError(
461
1439
  "atbash-sdk Atbash.from_config is missing "
@@ -490,13 +1468,17 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
490
1468
  # CLI parsers are last-flag-wins and a shell runs every `;`-separated
491
1469
  # statement. When the text can mean more than one thing, say so instead of
492
1470
  # picking a reading and asserting it.
493
- repeated_recipient = len(_flag_values(command, "to")) + len(
494
- _flag_values(command, "recipient")
495
- ) > 1
1471
+ # Fuzz family c: EVERY beneficiary alias counts here, not only
1472
+ # --to/--recipient — `approve --to <allowlisted> --spender <other>`
1473
+ # asserted a clean allowlisted reading while the approval's actual
1474
+ # beneficiary was the second address.
1475
+ recipient_values = _all_flag_values(command, _RECIPIENT_FLAG_NAMES)
1476
+ repeated_recipient = len(recipient_values) > 1
1477
+ amount_values = _all_flag_values(command, _AMOUNT_FLAG_NAMES)
496
1478
  repeated_flag = any(
497
1479
  len(_flag_values(command, name)) > 1
498
- for name in ("token", "amount", "max-usd")
499
- )
1480
+ for name in ("token", "max-usd")
1481
+ ) or len(amount_values) > 1
500
1482
  multi_statement = bool(_STATEMENT_SEP_RE.search(command))
501
1483
 
502
1484
  # "absent" and "present but unparsable" must not collapse to the same
@@ -505,15 +1487,32 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
505
1487
  # "allowlisted recipient, no amount stated" for a command that names a very
506
1488
  # large one — which is exactly what an "allow allowlisted USDT under N"
507
1489
  # policy would let through.
508
- raw_amount = _flag("amount")
1490
+ raw_amount = _first_flag_value(command, _AMOUNT_FLAG_NAMES)
509
1491
  raw_max_usd = _flag("max-usd")
510
1492
  amount = _numeric_amount(raw_amount)
511
1493
  max_usd = _numeric_amount(raw_max_usd)
512
- amount_unparsed = (raw_amount is not None and amount is None) or (
1494
+ amount_unparsed = any(_numeric_amount(v) is None for v in amount_values) or (
513
1495
  raw_max_usd is not None and max_usd is None
514
1496
  )
1497
+ positional_amount = _positional_amount(command)
1498
+ noncanonical_separator = _noncanonical_flag_separator(
1499
+ command, _RECIPIENT_FLAG_NAMES + _AMOUNT_FLAG_NAMES + ("max-usd", "token")
1500
+ )
1501
+
1502
+ # --spender is the beneficiary of an approval, judged like a recipient;
1503
+ # the beneficiary aliases share one priority order.
1504
+ to = _first_flag_value(command, _RECIPIENT_FLAG_NAMES)
515
1505
 
516
- if repeated_recipient or repeated_flag or multi_statement or amount_unparsed:
1506
+ # N-4: two or more distinct non-recipient addresses with no --token
1507
+ # flag means the asset is whichever the executor picks — not the first.
1508
+ _distinct_candidates = {
1509
+ a.lower()
1510
+ for a in _ERC20_RE.findall(command)
1511
+ if not to or a.lower() != to.lower()
1512
+ }
1513
+ multiple_token_candidates = not _flag("token") and len(_distinct_candidates) > 1
1514
+
1515
+ if repeated_recipient or repeated_flag or multi_statement or amount_unparsed or multiple_token_candidates or positional_amount is not None or noncanonical_separator:
517
1516
  return {
518
1517
  "operation": op,
519
1518
  "asset": "ambiguous",
@@ -527,6 +1526,10 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
527
1526
  if multi_statement
528
1527
  else "repeated flag"
529
1528
  if (repeated_recipient or repeated_flag)
1529
+ else "amount stated as a bare positional value, not a flag"
1530
+ if positional_amount is not None
1531
+ else "flag written with a separator a CLI may not read"
1532
+ if noncanonical_separator
530
1533
  else "amount is not a plain decimal number"
531
1534
  ),
532
1535
  # Conservative facts, so an asset-specific rule is still reachable.
@@ -543,7 +1546,6 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
543
1546
  ),
544
1547
  }
545
1548
 
546
- to = _flag("to") or _flag("recipient")
547
1549
  token = _flag("token")
548
1550
  if not token:
549
1551
  # Only an address that is not the recipient can be the token contract.
@@ -581,19 +1583,191 @@ def _canonicalize_financial(command: str, recipient_allowlist: set) -> Optional[
581
1583
  }
582
1584
 
583
1585
 
1586
+ def _has_financial_signal(value: Any) -> bool:
1587
+ """Same gate as the command-path canonicalizer: a transfer/send/swap/
1588
+ approve/erc20 word or a 0x address. Beneficiary aliases alone are
1589
+ email/copy/settings fields, not a transfer."""
1590
+ if isinstance(value, str):
1591
+ return bool(_FINANCIAL_OP_RE.search(value) or _ERC20_RE.search(value))
1592
+ if isinstance(value, dict):
1593
+ return any(_has_financial_signal(v) for v in value.values())
1594
+ if isinstance(value, (list, tuple)):
1595
+ return any(_has_financial_signal(v) for v in value)
1596
+ return False
1597
+
1598
+
1599
+ def _canonicalize_financial_args(
1600
+ tool_args: Dict[str, Any], recipient_allowlist: set
1601
+ ) -> Optional[Dict[str, Any]]:
1602
+ """Canonicalize a structured (non-command) financial tool call (N-4):
1603
+ wallet-shaped tools carry to/amount/token as fields, not a command.
1604
+
1605
+ Fuzz family c: picking the FIRST of to/recipient/spender/destination
1606
+ and ignoring the rest asserted allowlisted for whichever beneficiary
1607
+ the executor would pick, and a non-scalar amount (list/dict) was
1608
+ silently dropped into amount=None. Every beneficiary alias is read,
1609
+ distinct values are ambiguous, and an amount that is present but not
1610
+ a plain scalar is unparsed — never absent.
1611
+ """
1612
+ if not isinstance(tool_args, dict):
1613
+ return None
1614
+ # Command-path canonicalization only fires on transfer/send/swap/
1615
+ # approve/erc20 text or a 0x address. Without that gate, a send_email
1616
+ # `to` or a copy `destination` becomes operation: transfer.
1617
+ if not _has_financial_signal(tool_args):
1618
+ return None
1619
+ beneficiaries: list = []
1620
+ amounts: list = []
1621
+ tokens: list = []
1622
+ amount_unparsed = False
1623
+ structure_unparsed = False
1624
+ for k, v in tool_args.items():
1625
+ nk = _norm_key(k)
1626
+ if nk in _BENEFICIARY_ARG_KEYS_N or nk in _TOKEN_ARG_KEYS_N:
1627
+ # A list value names every element (a batch transfer); a
1628
+ # non-scalar the canonicalizer cannot read is unparsed, never
1629
+ # silently absent — the same rule amounts already follow.
1630
+ target = beneficiaries if nk in _BENEFICIARY_ARG_KEYS_N else tokens
1631
+ if isinstance(v, str) and v:
1632
+ target.append(v)
1633
+ elif isinstance(v, (list, tuple)):
1634
+ for item in v:
1635
+ if isinstance(item, str) and item:
1636
+ target.append(item)
1637
+ else:
1638
+ structure_unparsed = True
1639
+ elif v is not None:
1640
+ structure_unparsed = True
1641
+ elif nk in _AMOUNT_ARG_KEYS_N:
1642
+ if isinstance(v, (str, int, float)) and not isinstance(v, bool):
1643
+ amounts.append(str(v))
1644
+ else:
1645
+ # Present but not a plain scalar (list/dict/bool/None):
1646
+ # an amount the canonicalizer cannot read is unparsed,
1647
+ # never silently absent (fuzz family c).
1648
+ amount_unparsed = True
1649
+ if not beneficiaries and not amounts and not tokens and not amount_unparsed and not structure_unparsed:
1650
+ return None
1651
+ to = beneficiaries[0] if beneficiaries else None
1652
+ token = tokens[0] if tokens else None
1653
+ raw_amount = amounts[0] if amounts else None
1654
+ amount = _numeric_amount(raw_amount) if raw_amount is not None else None
1655
+ if raw_amount is not None and amount is None:
1656
+ amount_unparsed = True
1657
+ ambiguous = (
1658
+ amount_unparsed
1659
+ or structure_unparsed
1660
+ or len({b.lower() for b in beneficiaries}) > 1
1661
+ or len(set(amounts)) > 1
1662
+ or len({t.lower() for t in tokens}) > 1
1663
+ )
1664
+ if ambiguous:
1665
+ # Same rule as the command path: a contested reading must not assert
1666
+ # the first asset/amount. The judge rules on this block, and the SDK
1667
+ # redacts raw addresses, so a leftover USDT amount still looks clean.
1668
+ # strict_reading keeps the conservative facts (every asset named,
1669
+ # the largest amount, whether EVERY beneficiary is allowlisted) so
1670
+ # asset-specific deny rules stay reachable, as on the command path.
1671
+ parsed_amounts = [a for a in amounts if _numeric_amount(a) is not None]
1672
+ return {
1673
+ "operation": "transfer",
1674
+ "asset": "ambiguous",
1675
+ "amount": None,
1676
+ "max_usd": None,
1677
+ "recipient_status": "ambiguous",
1678
+ "ambiguous": True,
1679
+ "amount_unparsed": amount_unparsed,
1680
+ "strict_reading": {
1681
+ "assets_named": sorted({_resolve_asset(t) for t in tokens}),
1682
+ "assets_named_raw": sorted({t for t in tokens}),
1683
+ "max_amount_named": (
1684
+ max(parsed_amounts, key=lambda v: float(v))
1685
+ if parsed_amounts
1686
+ else ("unparsable" if amounts or amount_unparsed else None)
1687
+ ),
1688
+ "recipients_named": len(beneficiaries),
1689
+ "all_named_recipients_allowlisted": bool(beneficiaries)
1690
+ and all(b.lower() in recipient_allowlist for b in beneficiaries),
1691
+ "may_hide_other_actions": False,
1692
+ },
1693
+ "allowlist_scope": "USDT transfers/approvals only; never lifts ETH or ATBASH rules",
1694
+ "note": (
1695
+ "canonicalization withheld: the arguments carry more than one "
1696
+ "beneficiary, amount, or token, or an unreadable amount, so no "
1697
+ "asset/amount/recipient claim here can be trusted"
1698
+ ),
1699
+ }
1700
+ asset = _resolve_asset(token or "")
1701
+ on_allow = bool(to) and to.lower() in recipient_allowlist
1702
+ if asset == "USDT":
1703
+ recipient_status = "allowlisted" if on_allow else ("external-not-allowlisted" if to else "unspecified")
1704
+ else:
1705
+ recipient_status = "external" if to else "unspecified"
1706
+ return {
1707
+ "operation": "transfer",
1708
+ "asset": asset,
1709
+ "amount": amount,
1710
+ "max_usd": None,
1711
+ "recipient_status": recipient_status,
1712
+ "ambiguous": False,
1713
+ "amount_unparsed": amount_unparsed,
1714
+ "allowlist_scope": "USDT transfers/approvals only; never lifts ETH or ATBASH rules",
1715
+ "note": "canonicalized from structured tool arguments",
1716
+ }
1717
+
1718
+
1719
+ _BENEFICIARY_ARG_KEYS_N = frozenset(
1720
+ {"to", "recipient", "spender", "destination", "beneficiary", "payee", "target"}
1721
+ )
1722
+ _AMOUNT_ARG_KEYS_N = frozenset(
1723
+ {"amount", "value", "quantity", "usd", "amountusd", "amt", "sum", "total", "maxusd"}
1724
+ )
1725
+ _TOKEN_ARG_KEYS_N = frozenset({"token", "asset", "currency"})
1726
+
1727
+
584
1728
  def _extract_command(tool_name: str, tool_args: Dict[str, Any]) -> str:
585
1729
  if not isinstance(tool_args, dict):
586
1730
  return ""
1731
+ by_norm = _args_by_norm_key(tool_args)
587
1732
  for key in ("command", "cmd", "shell_command", "bash_command", "zsh_command", "script"):
588
- v = tool_args.get(key)
1733
+ v = by_norm.get(_norm_key(key))
589
1734
  if isinstance(v, str) and v.strip():
590
1735
  return v.strip()
591
- # Hermes terminal/shell tools sometimes nest payloads
1736
+ # N-3: an argv list is a command too — joining it is what the
1737
+ # executor effectively runs.
1738
+ if isinstance(v, (list, tuple)) and v and all(isinstance(x, str) for x in v):
1739
+ return " ".join(v)
1740
+ # Hermes terminal/shell tools sometimes nest payloads. Those same keys
1741
+ # are write-content aliases (`payload`/`input` for a file body), so a
1742
+ # write-class tool's document must not be treated as a shell command —
1743
+ # plain writes are judged on their path, and the body may mention guard
1744
+ # files.
1745
+ t = _normalize_tool_name(tool_name)
1746
+ # Name-based only: operation/method/action are attacker-controlled
1747
+ # classification inputs, so a shell tool must not be able to silence
1748
+ # command extraction by claiming operation="write" (its payload is then
1749
+ # judged as a command, as before). Generic operation-tools keep the
1750
+ # fail-closed treatment; the judge still sees their full arguments.
1751
+ write_class = t in _WRITE_TOOL_NAMES_N or t in _RENAME_TOOL_NAMES_N
1752
+ # Structural evidence this really is a file write: a picked target
1753
+ # path. A write-named tool with no target (script-runners, edit-and-run
1754
+ # tools) keeps the fail-closed command treatment for its payload, and
1755
+ # dict-nested bodies follow the same rule as string bodies.
1756
+ skip_body = write_class and bool(
1757
+ _pick_path(tool_args) or _pick_destination(tool_args)
1758
+ )
592
1759
  for key in ("input", "args", "payload"):
593
- v = tool_args.get(key)
1760
+ v = by_norm.get(_norm_key(key))
1761
+ if isinstance(v, str) and v.strip():
1762
+ if skip_body:
1763
+ continue
1764
+ return v.strip()
594
1765
  if isinstance(v, dict):
595
- for k in ("command", "cmd"):
596
- vv = v.get(k)
1766
+ if skip_body:
1767
+ continue
1768
+ nested = _args_by_norm_key(v)
1769
+ for k in ("command", "cmd", "script"):
1770
+ vv = nested.get(_norm_key(k))
597
1771
  if isinstance(vv, str) and vv.strip():
598
1772
  return vv.strip()
599
1773
  return ""
@@ -689,6 +1863,76 @@ def _extract_allow(raw: Any) -> Optional[bool]:
689
1863
  return None
690
1864
 
691
1865
 
1866
+ def _extract_action_type(raw: Any) -> str:
1867
+ """Read the SDK JudgeResult `action_type` (judge_action has no `allow`)."""
1868
+ action_attr = getattr(raw, "action_type", None)
1869
+ if isinstance(action_attr, str):
1870
+ return action_attr.strip().lower()
1871
+ if isinstance(raw, dict):
1872
+ v = raw.get("action_type")
1873
+ if isinstance(v, str):
1874
+ return v.strip().lower()
1875
+ return ""
1876
+
1877
+
1878
+ def _prospective_memory_content(
1879
+ tool_name: str, tool_args: Dict[str, Any], path: str, new_content: str
1880
+ ) -> Optional[str]:
1881
+ """The file's content AFTER this write, if it can be known before it runs.
1882
+
1883
+ A full write's content is the file. An edit's `new_string` is only a
1884
+ fragment (N-HERMES-02): baselining the fragment makes the very next read
1885
+ of the real file look tampered. For a plain old->new replacement the
1886
+ prospective content is computable from the file on disk; anything else
1887
+ returns None and the caller must not advance the baseline at all.
1888
+ """
1889
+ t = _normalize_tool_name(tool_name)
1890
+ if t in _PATCH_TOOL_NAMES_N:
1891
+ # A patch body is not the file's content; what lands on disk cannot
1892
+ # be computed here, so nothing may be baselined (the same rule as
1893
+ # an unresolvable edit fragment, N-HERMES-02).
1894
+ return None
1895
+ if t in _APPEND_TOOL_NAMES_N:
1896
+ try:
1897
+ current = Path(path).expanduser().read_text(encoding="utf-8", errors="replace")
1898
+ except FileNotFoundError:
1899
+ return new_content
1900
+ except Exception:
1901
+ return None
1902
+ return current + new_content
1903
+ if t not in _EDIT_TOOL_NAMES_N:
1904
+ return new_content
1905
+ try:
1906
+ current = Path(path).expanduser().read_text(encoding="utf-8", errors="replace")
1907
+ except Exception:
1908
+ return None
1909
+ pairs = []
1910
+ edits = tool_args.get("edits")
1911
+ if isinstance(edits, list) and edits:
1912
+ for e in edits:
1913
+ if not isinstance(e, dict):
1914
+ return None
1915
+ old = next((e[k] for k in ("old_string", "oldText", "old_text", "old_str") if isinstance(e.get(k), str) and e[k]), None)
1916
+ new = next((e[k] for k in ("new_string", "newText", "new_text", "newStr", "replacement") if isinstance(e.get(k), str)), None)
1917
+ if old is None or new is None:
1918
+ return None
1919
+ pairs.append((old, new))
1920
+ else:
1921
+ old = next((tool_args[k] for k in ("old_string", "oldText", "old_text", "old_str") if isinstance(tool_args.get(k), str) and tool_args[k]), None)
1922
+ if old is None:
1923
+ return None
1924
+ pairs.append((old, new_content))
1925
+ result = current
1926
+ for old, new in pairs:
1927
+ # An old string occurring more than once is ambiguous: real edit
1928
+ # tools may replace every occurrence, so which result lands on disk
1929
+ # cannot be known here (N-9) — baseline nothing rather than guess.
1930
+ if result.count(old) != 1:
1931
+ return None
1932
+ result = result.replace(old, new, 1)
1933
+ return result
1934
+
1935
+
692
1936
  def _extract_status(raw: Any) -> str:
693
1937
  """Read the judge's server-reported `status`, lower-cased ("" when absent).
694
1938
 
@@ -784,6 +2028,10 @@ def _setup_telemetry() -> None:
784
2028
  exporter issues must never prevent the guard from registering.
785
2029
  """
786
2030
  global _TELEMETRY_SHUTDOWN_REGISTERED
2031
+ # C-HERMES-17: telemetry stays on by default but has an explicit opt-out.
2032
+ if not _as_bool(os.getenv("ATBASH_TELEMETRY"), True):
2033
+ logger.debug("Atbash telemetry disabled via ATBASH_TELEMETRY")
2034
+ return
787
2035
  try:
788
2036
  from atbash.opentel import setup_telemetry, shutdown_telemetry # type: ignore
789
2037
 
@@ -814,15 +2062,12 @@ class AtbashHermesGuard:
814
2062
  self.tool_map = {**DEFAULT_TOOL_CLASS_MAP, **_load_tool_map(self.tool_map_path)}
815
2063
  self.recipient_allowlist = _load_recipient_allowlist()
816
2064
  self._snapshots: Dict[str, str] = {} # file_path → last-known-good content (in-process)
817
- hash_store_path = Path(
818
- os.getenv(
819
- "ATBASH_MEMORY_HASH_PATH",
820
- str(_default_tool_map_path().parent / "hermes-memory-hashes.json"),
821
- )
822
- ).expanduser()
823
- self._hash_store = _MemoryHashStore(hash_store_path)
2065
+ self._hash_store = _MemoryHashStore(_memory_hash_store_path())
824
2066
 
825
2067
  self.client = self._build_client()
2068
+ # H-6: the boot probe exists to catch between-session tampering at
2069
+ # startup; it was defined but never called.
2070
+ self._run_boot_probe()
826
2071
 
827
2072
  def _action_class(self, tool_name: str) -> str:
828
2073
  mapped = self.tool_map.get(tool_name)
@@ -830,8 +2075,20 @@ class AtbashHermesGuard:
830
2075
  return mapped
831
2076
 
832
2077
  action_class = _infer_action_class(tool_name)
833
- self.tool_map[tool_name or "unknown"] = action_class
834
- _save_tool_map(self.tool_map_path, self.tool_map)
2078
+ # C-HERMES-13: serialise the learn+persist side effect and cap the
2079
+ # learned map, so concurrent hook threads cannot corrupt the file
2080
+ # or grow it without bound.
2081
+ with _TOOL_MAP_LOCK:
2082
+ self.tool_map[tool_name or "unknown"] = action_class
2083
+ if len(self.tool_map) > _TOOL_MAP_MAX_ENTRIES + len(DEFAULT_TOOL_CLASS_MAP):
2084
+ learned = [
2085
+ (k, v) for k, v in self.tool_map.items() if k not in DEFAULT_TOOL_CLASS_MAP
2086
+ ]
2087
+ self.tool_map = {
2088
+ **DEFAULT_TOOL_CLASS_MAP,
2089
+ **dict(learned[-_TOOL_MAP_MAX_ENTRIES:]),
2090
+ }
2091
+ _save_tool_map(self.tool_map_path, self.tool_map)
835
2092
  logger.info(
836
2093
  "Atbash learned tool mapping tool=%s action_class=%s path=%s",
837
2094
  tool_name or "unknown",
@@ -842,39 +2099,41 @@ class AtbashHermesGuard:
842
2099
 
843
2100
  def _build_client(self):
844
2101
  try:
2102
+ import atbash as _sdk # type: ignore
845
2103
  from atbash import Atbash, ToolCallInput # type: ignore
846
2104
  except Exception as e:
847
2105
  try:
848
2106
  from atbash import Atbash # type: ignore
849
2107
  from atbash.types import ToolCallInput # type: ignore
2108
+
2109
+ import atbash as _sdk # type: ignore
850
2110
  except Exception:
851
- raise RuntimeError("atbash-sdk is required. Install with: pip install atbash-sdk==0.9.12.dev0") from e
2111
+ raise RuntimeError(
2112
+ "atbash-sdk is required. Install with: "
2113
+ "pip install 'atbash-sdk>=0.12.0.dev0,<1.0.0'"
2114
+ ) from e
2115
+
2116
+ # H-5: the capability check was defined but never called, so an SDK
2117
+ # missing the guard surface failed later (or silently) at judge time.
2118
+ _assert_sdk_capabilities(_sdk, Atbash, ToolCallInput)
852
2119
 
853
2120
  self.ToolCallInput = ToolCallInput
854
2121
  judge_cfg = self._judge_config()
855
2122
 
856
- # Prefer explicit key material if provided.
2123
+ # Prefer explicit key material if provided. H-4: no TypeError
2124
+ # fallback — catching TypeError here also caught unrelated SDK
2125
+ # internals and rebuilt the client WITHOUT the judge config
2126
+ # (endpoint policy / verify pubkey), silently changing which judge
2127
+ # was trusted. A construction failure now fails guard init, which
2128
+ # register() turns into a fail-closed hook.
857
2129
  if self.privkey:
858
- try:
859
- return Atbash.from_config(
860
- agent_key=self.privkey,
861
- judge=judge_cfg,
862
- org_name=self.org_name,
863
- fail_closed=self.fail_closed,
864
- **({} if self.judge_timeout is None else {"timeout": self.judge_timeout}),
865
- )
866
- except TypeError:
867
- kwargs: Dict[str, Any] = {
868
- "org_name": self.org_name,
869
- "fail_closed": self.fail_closed,
870
- }
871
- if self.endpoint:
872
- kwargs["endpoint"] = self.endpoint
873
- if self.judge_verify_pubkey:
874
- kwargs["verify_pubkey"] = self.judge_verify_pubkey
875
- if self.judge_timeout is not None:
876
- kwargs["timeout"] = self.judge_timeout
877
- return Atbash(self.privkey, **kwargs)
2130
+ return Atbash.from_config(
2131
+ agent_key=self.privkey,
2132
+ judge=judge_cfg,
2133
+ org_name=self.org_name,
2134
+ fail_closed=self.fail_closed,
2135
+ **({} if self.judge_timeout is None else {"timeout": self.judge_timeout}),
2136
+ )
878
2137
 
879
2138
  # Use SDK-native config loader for key file/env/config.
880
2139
  return Atbash.from_config(
@@ -888,11 +2147,23 @@ class AtbashHermesGuard:
888
2147
  def _judge_config(self) -> Optional[Dict[str, str]]:
889
2148
  if not self.endpoint:
890
2149
  return None
891
- if self.judge_endpoint_policy == "self-hosted":
2150
+ # H-7 (SDK semantics, atbash-sdk endpoint.py): verify_pubkey only
2151
+ # has meaning under the self-hosted policy, and setting it selects
2152
+ # that policy. Dropping a configured pubkey, or sending an empty
2153
+ # one, silently changed/disabled response-signature verification.
2154
+ policy = self.judge_endpoint_policy
2155
+ if policy is None and self.judge_verify_pubkey:
2156
+ policy = "self-hosted"
2157
+ if policy == "self-hosted":
2158
+ if not self.judge_verify_pubkey:
2159
+ raise RuntimeError(
2160
+ "ATBASH_JUDGE_ENDPOINT_POLICY=self-hosted requires "
2161
+ "ATBASH_JUDGE_VERIFY_PUBKEY (66-hex judge response-signing key)"
2162
+ )
892
2163
  return {
893
2164
  "policy": "self-hosted",
894
2165
  "endpoint": self.endpoint,
895
- "verify_pubkey": self.judge_verify_pubkey or "",
2166
+ "verify_pubkey": self.judge_verify_pubkey,
896
2167
  }
897
2168
  return {
898
2169
  "policy": "default",
@@ -938,7 +2209,12 @@ class AtbashHermesGuard:
938
2209
  "args": tool_args or {},
939
2210
  }
940
2211
 
2212
+ # N-11: re-read the recipient allowlist per call — a revocation must
2213
+ # not keep canonicalizing as "allowlisted" until the next restart.
2214
+ self.recipient_allowlist = _load_recipient_allowlist()
941
2215
  resolved = _canonicalize_financial(command, self.recipient_allowlist)
2216
+ if resolved is None:
2217
+ resolved = _canonicalize_financial_args(tool_args, self.recipient_allowlist)
942
2218
  tci = self.ToolCallInput(
943
2219
  tool_name=tool_name or "unknown",
944
2220
  args=action_payload,
@@ -1007,6 +2283,19 @@ class AtbashHermesGuard:
1007
2283
  logger.debug("Atbash memory diff skipped err=%s", e)
1008
2284
  return None
1009
2285
 
2286
+ def _advance_baseline(self, path: str, content: str) -> None:
2287
+ """Record `content` as the trusted baseline under every spelling of
2288
+ the path (raw + canonical), so a respelled read still hits it (H-9)."""
2289
+ self._snapshots[path] = content
2290
+ canonical = _memory_key(path)
2291
+ if canonical != path:
2292
+ self._snapshots[canonical] = content
2293
+ # N-11: the in-process snapshot cache is bounded; oldest entries go
2294
+ # first (the persistent hash baseline still protects those files).
2295
+ while len(self._snapshots) > 512:
2296
+ self._snapshots.pop(next(iter(self._snapshots)))
2297
+ self._hash_store.set(path, _sha256_hex(content))
2298
+
1010
2299
  def _handle_memory_write(
1011
2300
  self,
1012
2301
  tool_name: str,
@@ -1020,9 +2309,21 @@ class AtbashHermesGuard:
1020
2309
  """Gate a memory write: local diff → judge with memory context → update snapshot."""
1021
2310
  path = mem_entry["key"]
1022
2311
  new_content = mem_entry["value"]
2312
+ # What the file will contain after the tool runs (None when an edit
2313
+ # fragment cannot be resolved — then nothing may be baselined).
2314
+ baseline_content = _prospective_memory_content(
2315
+ tool_name, tool_args, path, new_content
2316
+ )
1023
2317
 
1024
- # Step 1: local anomaly check against cached baseline.
1025
- anomaly = self._diff_memory(path, self._snapshots.get(path), new_content)
2318
+ # Step 1: local anomaly check against cached baseline. Diff the
2319
+ # prospective full file, not an edit fragment — snapshots hold the
2320
+ # whole file (N-HERMES-02), so comparing them to `new_string` looks
2321
+ # like a wipe on the next allowed edit.
2322
+ cached = self._snapshots.get(_memory_key(path), self._snapshots.get(path))
2323
+ if baseline_content is not None:
2324
+ anomaly = self._diff_memory(path, cached, baseline_content)
2325
+ else:
2326
+ anomaly = None
1026
2327
  if anomaly:
1027
2328
  logger.warning("Atbash memory write anomaly path=%s desc=%s", path, anomaly)
1028
2329
  if self.fail_closed:
@@ -1042,11 +2343,26 @@ class AtbashHermesGuard:
1042
2343
  # Redact embedded secrets before sending to the judge — mirrors SDK's
1043
2344
  # scanMemory → redactSecrets → judgeAction path to prevent secrets inside
1044
2345
  # memory content from being signed and posted to chain.
2346
+ if not _REDACT_AVAILABLE:
2347
+ # N-8: unredacted memory content must never be signed and
2348
+ # posted — a broken/partial SDK is a block, not a silent send.
2349
+ logger.warning("Atbash memory write redaction unavailable")
2350
+ if self.fail_closed:
2351
+ return {
2352
+ "action": "block",
2353
+ "message": "Memory write blocked (secret redaction unavailable).",
2354
+ }
2355
+ return None
1045
2356
  try:
1046
- from atbash_core import redact_secrets # type: ignore
1047
- formatted = redact_secrets(formatted).redacted
2357
+ formatted = _redact_secrets(formatted).redacted # type: ignore
1048
2358
  except Exception as _re:
1049
- logger.debug("Atbash memory write redact skipped err=%s", _re)
2359
+ logger.warning("Atbash memory write redaction failed err=%s", _re)
2360
+ if self.fail_closed:
2361
+ return {
2362
+ "action": "block",
2363
+ "message": "Memory write blocked (secret redaction unavailable).",
2364
+ }
2365
+ return None
1050
2366
 
1051
2367
  verdict_raw = self.client.judge_action(
1052
2368
  formatted,
@@ -1056,7 +2372,7 @@ class AtbashHermesGuard:
1056
2372
  )
1057
2373
  verdict = _normalize_verdict(verdict_raw)
1058
2374
  reason = _extract_reason(verdict_raw)
1059
- _, clean_reason = _parse_score_from_reason(reason)
2375
+ score, clean_reason = _parse_score_from_reason(reason)
1060
2376
 
1061
2377
  if self.debug:
1062
2378
  logger.info(
@@ -1098,9 +2414,12 @@ class AtbashHermesGuard:
1098
2414
  # judge is least trustworthy AND make it the new tamper
1099
2415
  # baseline. Same rule as the SDK's audit_tool_call.
1100
2416
  status = _extract_status(verdict_raw)
1101
- if status == "logged":
1102
- self._snapshots[path] = new_content
1103
- self._hash_store.set(path, _sha256_hex(new_content))
2417
+ if status == "logged" and _as_bool(os.getenv("ATBASH_ALLOW_AUDIT_TIER"), False):
2418
+ # Monitor-only (audit tier) deployments opt in explicitly;
2419
+ # otherwise an unjudged "logged" answer neither passes nor
2420
+ # becomes a trusted baseline (Atbash decision 2026-09-30).
2421
+ if baseline_content is not None:
2422
+ self._advance_baseline(path, baseline_content)
1104
2423
  return None
1105
2424
  logger.warning(
1106
2425
  "Atbash memory write no verdict without audit marker path=%s status=%s reason=%s",
@@ -1122,15 +2441,36 @@ class AtbashHermesGuard:
1122
2441
  if self.fail_closed:
1123
2442
  return {
1124
2443
  "action": "block",
1125
- "message": f"Memory write blocked (Atbash error): {reason}",
2444
+ # Judge error bodies are not echoed to the model.
2445
+ "message": "Memory write blocked (Atbash error).",
1126
2446
  }
1127
2447
  # fail-open: allow but do NOT advance baseline — content was not scanned.
1128
2448
  return None
1129
2449
 
2450
+ # H-10: judge_action returns JudgeResult (no `allow` field; its
2451
+ # vouch is action_type == "allow"); audit-style replies vouch via
2452
+ # allow is True. A bare ALLOW carrying neither is not permission.
2453
+ # N-5: a judge that says ALLOW in one field and "actively
2454
+ # poisonous" (SCORE 1-4) in its own reason contradicts itself —
2455
+ # that reply is not permission and baselines nothing.
2456
+ if verdict == "ALLOW" and score is not None and score <= 4:
2457
+ logger.warning(
2458
+ "Atbash memory write ALLOW contradicted by score path=%s score=%s", path, score
2459
+ )
2460
+ if self.fail_closed:
2461
+ return {
2462
+ "action": "block",
2463
+ "message": "Memory write blocked (judge verdict contradicted by its score).",
2464
+ }
2465
+ return None
2466
+
1130
2467
  allow_flag = _extract_allow(verdict_raw)
1131
- if verdict == "ALLOW" and allow_flag is not False:
1132
- self._snapshots[path] = new_content
1133
- self._hash_store.set(path, _sha256_hex(new_content))
2468
+ if verdict == "ALLOW" and (
2469
+ allow_flag is True
2470
+ or (allow_flag is None and _extract_action_type(verdict_raw) == "allow")
2471
+ ):
2472
+ if baseline_content is not None:
2473
+ self._advance_baseline(path, baseline_content)
1134
2474
  return None
1135
2475
 
1136
2476
  logger.warning(
@@ -1153,7 +2493,8 @@ class AtbashHermesGuard:
1153
2493
  if self.fail_closed:
1154
2494
  return {
1155
2495
  "action": "block",
1156
- "message": f"Memory write blocked (Atbash unavailable): {e}",
2496
+ # Exception text (endpoints, tokens) stays in the log only.
2497
+ "message": "Memory write blocked (Atbash unavailable).",
1157
2498
  }
1158
2499
  return None
1159
2500
 
@@ -1173,7 +2514,14 @@ class AtbashHermesGuard:
1173
2514
  except FileNotFoundError:
1174
2515
  return None # File doesn't exist — allow
1175
2516
  except Exception as e:
1176
- logger.debug("Atbash memory read file error path=%s err=%s", path, e)
2517
+ # H-8: an unreadable memory path (a directory, a permission
2518
+ # error) is not a clean read. Fail-closed means it does not pass.
2519
+ logger.warning("Atbash memory read file error path=%s err=%s", path, e)
2520
+ if self.fail_closed:
2521
+ return {
2522
+ "action": "block",
2523
+ "message": "Memory read blocked (memory path could not be read).",
2524
+ }
1177
2525
  return None
1178
2526
 
1179
2527
  # ── Cross-session integrity check (persistent hash) ───────────────────
@@ -1181,7 +2529,31 @@ class AtbashHermesGuard:
1181
2529
  # cannot detect. Mirrors the role of openclaw's PointerStore: openclaw
1182
2530
  # catches drift via chain; hermes catches it via a persisted SHA-256.
1183
2531
  stored_hash = self._hash_store.get(path)
1184
- if stored_hash is not None:
2532
+ if stored_hash is None:
2533
+ # No persisted baseline means the guard has never seen this file.
2534
+ # A memory file dropped between sessions by an attacker is
2535
+ # untrusted, not trusted — in enforce mode it does not reach the
2536
+ # agent; in monitor mode it is logged and the in-process drift
2537
+ # check below still applies.
2538
+ logger.warning(
2539
+ "Atbash memory read has no trusted baseline path=%s — treating as untrusted",
2540
+ path,
2541
+ )
2542
+ if self.fail_closed:
2543
+ # Untrusted until a person trusts it. Nothing is sent to the
2544
+ # judge on a read: that would ship pre-existing private files
2545
+ # off the machine and let a poisoned file be re-read until a
2546
+ # judge said yes. A judged write through the guard, or a human
2547
+ # seed, records the baseline.
2548
+ return {
2549
+ "action": "block",
2550
+ "message": (
2551
+ "Memory read blocked: this file has no trusted baseline. A person must "
2552
+ "review and trust it outside the agent - see 'Guard Self-Protection And "
2553
+ "Memory Baselines' in the Atbash Hermes plugin README."
2554
+ ),
2555
+ }
2556
+ else:
1185
2557
  current_hash = _sha256_hex(current)
1186
2558
  if current_hash != stored_hash:
1187
2559
  logger.warning(
@@ -1199,7 +2571,7 @@ class AtbashHermesGuard:
1199
2571
  }
1200
2572
 
1201
2573
  # ── Within-session drift check (in-process snapshot) ──────────────────
1202
- cached = self._snapshots.get(path)
2574
+ cached = self._snapshots.get(_memory_key(path), self._snapshots.get(path))
1203
2575
  if cached is None:
1204
2576
  return None # No in-process baseline yet — allow
1205
2577
 
@@ -1213,6 +2585,117 @@ class AtbashHermesGuard:
1213
2585
  }
1214
2586
  return None
1215
2587
 
2588
+ def _refuse_guard_state_write(
2589
+ self, tool_name: str, tool_args: Dict[str, Any]
2590
+ ) -> Optional[Dict[str, Any]]:
2591
+ """Refuse writes aimed at the guard's own state, before any judging.
2592
+
2593
+ Tool map, recipient allowlist, persisted memory-hash baseline and the
2594
+ agent key are the checks' foundation; letting a tool call rewrite them
2595
+ silently disarms the guard. The refusal is unconditional — it is a
2596
+ policy refusal, not a judge verdict, so monitor mode does not soften it.
2597
+ """
2598
+ t = _normalize_tool_name(tool_name)
2599
+ if _invokes_seeder(tool_args):
2600
+ logger.warning("Atbash refused an agent call to the baseline seeder tool=%s", tool_name)
2601
+ return {
2602
+ "action": "block",
2603
+ "message": (
2604
+ "Refused: trusting a memory file (seeding its baseline) is a human "
2605
+ "step - run it yourself, outside the agent"
2606
+ ),
2607
+ }
2608
+ extra = []
2609
+ for attr in ("tool_map_path", "key_path"):
2610
+ v = getattr(self, attr, None)
2611
+ if v:
2612
+ extra.append(Path(str(v)).expanduser())
2613
+ key_path = getattr(self, "key_path", None)
2614
+ if key_path:
2615
+ # The key's directory too, when it is a dedicated Atbash directory
2616
+ # (the README's Docker setup keeps it in /opt/data/atbash). A key
2617
+ # kept in a general directory (the home folder) protects only the
2618
+ # key file itself, or every write there would be refused.
2619
+ key_dir = Path(str(key_path)).expanduser().parent
2620
+ project_markers = (".git", "package.json", "pyproject.toml", "setup.py", "Cargo.toml", "go.mod")
2621
+ is_project = any((key_dir / m).exists() for m in project_markers)
2622
+ if "atbash" in key_dir.name.lower() and not is_project:
2623
+ extra.append(key_dir)
2624
+ store = getattr(self, "_hash_store", None)
2625
+ store_path = getattr(store, "_path", None)
2626
+ if store_path:
2627
+ extra.append(Path(str(store_path)).expanduser())
2628
+
2629
+ def _refusal(hit: str) -> Dict[str, Any]:
2630
+ logger.warning("Atbash refused write to guard state tool=%s path=%s", tool_name, hit)
2631
+ return {
2632
+ "action": "block",
2633
+ "message": (
2634
+ "Refused: writes to the Atbash guard's own state (configuration, "
2635
+ "allowlist, hash baseline, agent key, and Hermes's .env, config.yaml "
2636
+ "and SOUL.md) are not permitted. To change one of these, edit the "
2637
+ "file yourself, outside the agent"
2638
+ ),
2639
+ }
2640
+
2641
+ # N-1: a shell/terminal command that names guard state is a guard-
2642
+ # state write attempt whatever the tool is called — `terminal` was
2643
+ # mapped to command_execution and skipped this refusal entirely.
2644
+ command = _extract_command(tool_name, tool_args)
2645
+ if command:
2646
+ if _mentions_guard_state({"command": command}, extra):
2647
+ return _refusal("<named in the command>")
2648
+ # Fuzz family b: needle matching alone misses spellings only
2649
+ # the OS resolves (admin shares, \\?\ prefixes, root-relative,
2650
+ # dotted components) — test each path-like token by identity.
2651
+ for token in _command_path_tokens(command):
2652
+ if _is_guard_state_path(token, extra):
2653
+ return _refusal(token)
2654
+
2655
+ # N-2: classify by what the call DOES, not only its name — an
2656
+ # operation-discriminated tool (operation/method/action = write…)
2657
+ # and rename/move destinations are write-class too. Fuzz family d:
2658
+ # the name is matched spelling-normalised against the same single
2659
+ # write-class surface the memory router uses.
2660
+ is_write_class = (
2661
+ t in _WRITE_TOOL_NAMES_N
2662
+ or t in _RENAME_TOOL_NAMES_N
2663
+ or _is_write_operation(tool_args)
2664
+ or getattr(self, "tool_map", {}).get(tool_name) == "filesystem_write"
2665
+ )
2666
+ if not is_write_class:
2667
+ return None
2668
+ path = _pick_path(tool_args)
2669
+ # A rename/move lands at its destination — but moving a guard file
2670
+ # AWAY disarms the guard at next start exactly as editing it would,
2671
+ # so a true move is refused when either end is guard state. A copy
2672
+ # only writes its destination; its source is a read.
2673
+ if t in _RENAME_TOOL_NAMES_N:
2674
+ destination = _pick_destination(tool_args)
2675
+ if destination:
2676
+ if t in _MOVE_TOOL_NAMES_N:
2677
+ # The source arrives either as the picked path or under
2678
+ # a source spelling _pick_path cannot see (it returns
2679
+ # the destination then); check every candidate that is
2680
+ # not the destination itself.
2681
+ for candidate in (_pick_source(tool_args), path):
2682
+ if (
2683
+ candidate
2684
+ and candidate != destination
2685
+ and _is_guard_state_path(candidate, extra)
2686
+ ):
2687
+ return _refusal(candidate)
2688
+ path = destination
2689
+ patch_shaped = t in _PATCH_TOOL_NAMES_N or not path
2690
+ hit_path = bool(path) and _is_guard_state_path(path, extra)
2691
+ # Patch-shaped tools carry their real targets in the body, so a harmless
2692
+ # `path` next to a body that names guard state is still refused. Plain
2693
+ # writes are judged on their path only (their content may mention it).
2694
+ hit_body = patch_shaped and _mentions_guard_state(tool_args, extra)
2695
+ if not (hit_path or hit_body):
2696
+ return None
2697
+ return _refusal(path if hit_path else "<named in the tool arguments>")
2698
+
1216
2699
  def pre_tool_call(
1217
2700
  self,
1218
2701
  *,
@@ -1223,6 +2706,23 @@ class AtbashHermesGuard:
1223
2706
  tool_call_id: str = "",
1224
2707
  **hook_context: Any,
1225
2708
  ):
2709
+ # H-3: normalise the host's call shape BEFORE any classification.
2710
+ # The preamble below (guard-state refusal, memory routing) runs
2711
+ # outside the judged try, so a non-dict `args` or a None/non-string
2712
+ # `tool_name` used to raise AttributeError there — and Hermes
2713
+ # isolates hook exceptions by running the tool unjudged.
2714
+ if not isinstance(tool_name, str):
2715
+ tool_name = ""
2716
+ if args is not None and not isinstance(args, dict):
2717
+ logger.warning(
2718
+ "Atbash pre_tool_call got non-object args type=%s", type(args).__name__
2719
+ )
2720
+ if self.fail_closed:
2721
+ return {
2722
+ "action": "block",
2723
+ "message": "Blocked (tool call arguments were not an object).",
2724
+ }
2725
+ return None
1226
2726
  if self.debug:
1227
2727
  logger.info(
1228
2728
  "Atbash pre_tool_call enter tool=%s session_id=%s task_id=%s tool_call_id=%s extra_keys=%s",
@@ -1234,12 +2734,41 @@ class AtbashHermesGuard:
1234
2734
  )
1235
2735
  tool_args = args or {}
1236
2736
 
2737
+ # N-3: two different destinations in one call means the guard and
2738
+ # the executor can read different files — never guess which. Keys
2739
+ # are matched spelling-normalised over the same alias list the
2740
+ # path picker accepts (fuzz family d). Collect from the raw args:
2741
+ # merging by _norm_key first hid distinct values under two
2742
+ # spellings of the same key (`file_path` vs `FILE_PATH`).
2743
+ _path_keys_n = {_norm_key(k) for k in _PATH_KEY_PRIORITY}
2744
+ _destinations = {
2745
+ v.strip()
2746
+ for k, v in tool_args.items()
2747
+ if _norm_key(k) in _path_keys_n and isinstance(v, str) and v.strip()
2748
+ }
2749
+ if len(_destinations) > 1 and _normalize_tool_name(tool_name) not in _RENAME_TOOL_NAMES_N:
2750
+ logger.warning("Atbash blocked conflicting destination keys tool=%s", tool_name)
2751
+ if self.fail_closed:
2752
+ return {
2753
+ "action": "block",
2754
+ "message": "Blocked (conflicting destination paths in one tool call).",
2755
+ }
2756
+ return None
2757
+
2758
+ # ── Guard self-protection ────────────────────────────────────────────
2759
+ # Refuse writes aimed at the guard's own state before any judging or
2760
+ # memory handling — the tool map, allowlist, hash baseline and agent
2761
+ # key are what every other check stands on.
2762
+ refusal = self._refuse_guard_state_write(tool_name, tool_args)
2763
+ if refusal is not None:
2764
+ return refusal
2765
+
1237
2766
  # ── Memory write path ────────────────────────────────────────────────
1238
2767
  # Mirrors openclaw's guard.handleBeforeToolCall: memory writes skip the
1239
2768
  # regular audit and go through the dedicated memory guard (diff → judge).
1240
2769
  mem_entry = _classify_memory_write(tool_name, tool_args)
1241
2770
  if mem_entry is not None:
1242
- return self._handle_memory_write(
2771
+ mem_result = self._handle_memory_write(
1243
2772
  tool_name=tool_name,
1244
2773
  tool_args=tool_args,
1245
2774
  mem_entry=mem_entry,
@@ -1248,6 +2777,10 @@ class AtbashHermesGuard:
1248
2777
  tool_call_id=tool_call_id,
1249
2778
  hook_context=hook_context,
1250
2779
  )
2780
+ # A patch that also lands outside memory is judged on its full
2781
+ # arguments as well; the memory scan alone never clears it.
2782
+ if mem_result is not None or not mem_entry.get("judge_rest"):
2783
+ return mem_result
1251
2784
 
1252
2785
  # ── Memory read path ─────────────────────────────────────────────────
1253
2786
  # Check if local file has drifted from cached snapshot (tampering signal).
@@ -1303,7 +2836,8 @@ class AtbashHermesGuard:
1303
2836
  if self.fail_closed:
1304
2837
  return {
1305
2838
  "action": "block",
1306
- "message": f"Blocked (Atbash judge error): {reason}",
2839
+ # Judge error bodies are not echoed to the model.
2840
+ "message": "Blocked (Atbash judge error).",
1307
2841
  }
1308
2842
  return None
1309
2843
 
@@ -1338,7 +2872,8 @@ class AtbashHermesGuard:
1338
2872
  if self.fail_closed:
1339
2873
  return {
1340
2874
  "action": "block",
1341
- "message": f"Blocked (Atbash unavailable): {e}",
2875
+ # Exception text stays in the log only.
2876
+ "message": "Blocked (Atbash unavailable).",
1342
2877
  }
1343
2878
  return None
1344
2879
 
@@ -1348,13 +2883,25 @@ def register(ctx):
1348
2883
  state: Dict[str, Any] = {"guard": None, "error": "guard initialization incomplete"}
1349
2884
 
1350
2885
  def fail_closed_pre_tool_call(**kwargs: Any):
2886
+ # The hook must never raise: Hermes isolates hook exceptions by
2887
+ # running the tool unjudged, so any crash here — including a
2888
+ # BaseException from deep in the guard — becomes a decision.
1351
2889
  guard = state["guard"]
1352
2890
  if guard is None:
1353
2891
  return {
1354
2892
  "action": "block",
1355
- "message": f"Blocked (Atbash guard unavailable): {state['error']}",
2893
+ "message": "Blocked (Atbash guard unavailable).",
2894
+ }
2895
+ try:
2896
+ return guard.pre_tool_call(**kwargs)
2897
+ except BaseException as error: # noqa: BLE001 — deliberate, see above
2898
+ logger.error("Atbash pre_tool_call crashed err=%s", error)
2899
+ if not getattr(guard, "fail_closed", True):
2900
+ return None
2901
+ return {
2902
+ "action": "block",
2903
+ "message": "Blocked (Atbash guard error).",
1356
2904
  }
1357
- return guard.pre_tool_call(**kwargs)
1358
2905
 
1359
2906
  # Hermes isolates plugin-load exceptions and continues startup, so install
1360
2907
  # the blocking hook before any SDK/config initialization can fail.