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.
- atbash_hermes_plugin/__init__.py +1663 -116
- atbash_hermes_plugin/__main__.py +49 -0
- {atbash_hermes_plugin-0.9.12.dev0.dist-info → atbash_hermes_plugin-0.12.0.dev0.dist-info}/METADATA +67 -5
- atbash_hermes_plugin-0.12.0.dev0.dist-info/RECORD +9 -0
- atbash_hermes_plugin-0.9.12.dev0.dist-info/RECORD +0 -8
- {atbash_hermes_plugin-0.9.12.dev0.dist-info → atbash_hermes_plugin-0.12.0.dev0.dist-info}/WHEEL +0 -0
- {atbash_hermes_plugin-0.9.12.dev0.dist-info → atbash_hermes_plugin-0.12.0.dev0.dist-info}/entry_points.txt +0 -0
- {atbash_hermes_plugin-0.9.12.dev0.dist-info → atbash_hermes_plugin-0.12.0.dev0.dist-info}/licenses/LICENSE +0 -0
- {atbash_hermes_plugin-0.9.12.dev0.dist-info → atbash_hermes_plugin-0.12.0.dev0.dist-info}/licenses/NOTICE +0 -0
- {atbash_hermes_plugin-0.9.12.dev0.dist-info → atbash_hermes_plugin-0.12.0.dev0.dist-info}/top_level.txt +0 -0
atbash_hermes_plugin/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
252
|
-
|
|
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",
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
282
|
-
|
|
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 =
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
314
|
-
if t in
|
|
1202
|
+
t = _normalize_tool_name(tool_name)
|
|
1203
|
+
if t in _MEMORY_READ_TOOL_NAMES_N:
|
|
315
1204
|
return True
|
|
316
|
-
if t in
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
445
|
-
"install atbash-sdk
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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", "
|
|
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 =
|
|
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 = (
|
|
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
|
-
|
|
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 =
|
|
1733
|
+
v = by_norm.get(_norm_key(key))
|
|
589
1734
|
if isinstance(v, str) and v.strip():
|
|
590
1735
|
return v.strip()
|
|
591
|
-
|
|
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 =
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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(
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1047
|
-
formatted = redact_secrets(formatted).redacted
|
|
2357
|
+
formatted = _redact_secrets(formatted).redacted # type: ignore
|
|
1048
2358
|
except Exception as _re:
|
|
1049
|
-
logger.
|
|
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
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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.
|