-
Notifications
You must be signed in to change notification settings - Fork 51
Expand file tree
/
Copy pathaudit-cli-surface.py
More file actions
255 lines (238 loc) · 11.6 KB
/
Copy pathaudit-cli-surface.py
File metadata and controls
255 lines (238 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import argparse
import re
import sys
import pathlib
repo = pathlib.Path(__file__).resolve().parent.parent
def load_real_commands(help_text):
"""Parse the subcommand brace list from `hermes --help` output."""
m = re.search(r"\{(chat,model[^}]+)\}", help_text)
if not m:
return None
cmds = {c for c in m.group(1).split(",") if c}
# subcommands defined outside the argparse choices brace (verified v0.20.4)
cmds |= {"photon"}
return cmds
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--help-file", default=None,
help="Path to saved `hermes --help` output (CI-friendly). "
"Default: run `hermes --help` live.")
ap.add_argument("--commands-file", default=None,
help="Path to a one-command-per-line list (e.g. output of "
"extract-upstream-commands.py). Overrides --help-file.")
ap.add_argument("--slash-file", default=None,
help="Path to a one-slash-command-per-line list "
"(extract-upstream-surface.py slash-commands.txt).")
ap.add_argument("--config-file", default=None,
help="Path to a one-config-key-path-per-line list; entries "
"ending in .* act as wildcards.")
ap.add_argument("--exit-on-drift", action="store_true",
help="Exit non-zero if any referenced subcommand is missing.")
args = ap.parse_args()
if args.commands_file:
with open(args.commands_file, encoding="utf-8") as fh:
real = {ln.strip() for ln in fh if ln.strip()}
source = args.commands_file
elif args.help_file:
with open(args.help_file, encoding="utf-8") as fh:
real = load_real_commands(fh.read())
source = args.help_file
else:
import subprocess
out = subprocess.run(["hermes", "--help"], capture_output=True,
text=True, timeout=120).stdout
real = load_real_commands(out)
source = "hermes --help (live)"
if real is None:
print("FATAL: could not parse command list from", source)
return 2
pat = re.compile(r"(?:^|[^\w-])hermes\s+([a-zA-Z][a-zA-Z0-9-]{1,30})")
quote_pat = re.compile(r"\"[^\"]*\"|'[^']*'")
skip = {"--help", "--version", "|", "is", "the", "you", "run", "will", "user", "installer"}
mentions = {}
def code_lines(md_text):
"""Yield only code-context lines: fenced blocks + inline `code` spans."""
in_fence = False
for line in md_text.splitlines():
stripped = line.lstrip()
if stripped.startswith("```"):
in_fence = not in_fence
continue
if in_fence:
yield line
else:
for span in re.findall(r"`([^`\n]+)`", line):
yield span
for f in sorted(repo.glob("*.md")):
if f.name in ("README-zh.md", "README-ja.md", "CHANGELOG.md"):
continue
text = f.read_text(encoding="utf-8")
for line in code_lines(text):
line = quote_pat.sub("", line) # drop string literals
for m in pat.finditer(line):
cmd = m.group(1)
if cmd in skip:
continue
mentions.setdefault(cmd, set()).add(f.name)
all_mentioned = set(mentions.keys())
missing = sorted(m for m in all_mentioned if m not in real)
print("Sources:", source)
print("Real commands:", len(real))
print("Distinct commands mentioned in guide:", len(all_mentioned))
print()
print("=== MENTIONED IN GUIDE BUT NOT IN REAL CLI (%d) ===" % len(missing))
for m in missing:
files = sorted(mentions[m])[:5]
print(" %-18s -> %s" % (m, files))
print()
used_real = sorted(all_mentioned - set(missing))
print("Real commands actually referenced:", len(used_real))
drift_count = len(missing)
# ---- slash-command audit ----
if args.slash_file:
with open(args.slash_file, encoding="utf-8") as fh:
real_slash = {ln.strip() for ln in fh if ln.strip()}
slash_pat = re.compile(r"`/([a-z][a-z0-9_-]{1,25})`")
slash_mentions = {}
# Known NON-Hermes slash tokens that legitimately appear in the guide.
# Each documented so this list stays auditable.
SLASH_SKIP = {
# Telegram BotFather commands (part4/part27/part28), not Hermes.
"newbot", "mybots", "revoke", "setuserpic", "setcommands",
"setdescription", "setabouttext", "setprivacy", "setname",
# Dashboard HTTP routes (part12) — verified present in
# upstream hermes_cli/web_server.py, not CLI slash commands.
"backup", "dump", "security-audit", "prompt-size", "chat", "import",
# Provider/platform API endpoint paths (part9/part15).
"messages", "models", "completions", "bluebubbles-webhook",
}
# skip common URL/path fragments that look like /word
slash_skip = {"usr", "bin", "tmp", "etc", "opt", "home", "dev", "var",
"v1", "api", "com", "issues", "raw", "docs"} | SLASH_SKIP
for f in sorted(repo.glob("*.md")):
if f.name in ("README-zh.md", "README-ja.md", "CHANGELOG.md"):
continue
text = f.read_text(encoding="utf-8")
in_fence = False
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith("```"):
in_fence = not in_fence
continue
if in_fence:
continue # /paths inside code blocks are usually paths/URLs
for m in slash_pat.finditer(line):
cmd = m.group(1)
if cmd in slash_skip:
continue
slash_mentions.setdefault(cmd, set()).add(f.name)
slash_missing = sorted(m for m in slash_mentions if m not in real_slash)
print()
print("=== SLASH: mentioned but NOT in upstream COMMAND_REGISTRY (%d) ==="
% len(slash_missing))
for m in slash_missing:
print(" /%-16s -> %s" % (m, sorted(slash_mentions[m])[:5]))
drift_count += len(slash_missing)
print("Slash surface checked: %d real, %d referenced"
% (len(real_slash), len(slash_mentions)))
# ---- config-key audit ----
if args.config_file:
with open(args.config_file, encoding="utf-8") as fh:
real_keys = {ln.strip() for ln in fh if ln.strip()}
wildcards = {k[:-2] for k in real_keys if k.endswith(".*")}
exact = {k for k in real_keys if not k.endswith(".*")}
def key_is_real(k):
if k in exact:
return True
return any(k == w or k.startswith(w + ".") for w in wildcards)
# File-extension heuristic: a "config key" whose last segment is a
# file extension is a filename (state.db, config.yaml, mcp.json),
# not a config path.
FILE_EXT = re.compile(r"\.(json|jsonl|ya?ml|py|db|cpp|gz|sh|service|sqlite|txt|md|log)$")
# Curated false-positive skip-list. Each entry is NOT a Hermes config
# key even though it looks like one. Reason documented inline so this
# list stays honest and auditable.
CFG_SKIP = {
# OpenTelemetry / observability SPAN NAMES documented in part20,
# not config keys.
"agent.turn", "llm.call", "tool.call", "memory.search", "skill.load",
"kanban.task", "kanban.worker", "browser_use.launch",
# Explicit "these keys are NOT real — don't paste them" negations
# (part20 / part19 teach readers what does NOT exist).
"compression.auto.at_tokens", "preserve.tool_results_matching",
"approval.require_approval", "secrets.scope",
"network.egress_allowlist", "security.network.egress_allowlist",
# OpenClaw SOURCE-side keys in part2's migration mapping table
# (left column = where they come FROM, not Hermes keys).
"agents.defaults.model", "agents.defaults.compaction.mode",
"agents.defaults.verboseDefault", "agents.defaults.thinkingDefault",
# Message-metadata field (part22 blueprints), not config.
"metadata.hermes.blueprint",
# Per-MCP-server tool knobs. Real keys, but written in BARE form
# (tools.include / tools.exclude / ...) inside the per-server
# context of part17/part19/part28 — the full path is
# mcp_servers.<name>.tools.include (mcp_config.py reads all four).
"tools.include", "tools.exclude", "tools.prompts", "tools.resources",
}
cfg_pat = re.compile(r"`((?:[a-z][a-z0-9_]*\.){1,4}[a-z][a-z0-9_.]*)`")
cfg_mentions = {}
scan_files = sorted(repo.glob("*.md")) + sorted((repo / "templates").rglob("*.yaml"))
for f in scan_files:
if f.name in ("README-zh.md", "README-ja.md", "CHANGELOG.md"):
continue
text = f.read_text(encoding="utf-8")
relname = str(f.relative_to(repo)).replace("\\", "/")
if f.suffix == ".yaml":
# YAML templates: reconstruct dotted paths from indentation
stack = []
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip())
m = re.match(r"\s*([a-z][a-zA-Z0-9_]*):", line)
if not m:
continue
while stack and stack[-1][0] >= indent:
stack.pop()
stack.append((indent, m.group(1)))
key = ".".join(seg for _, seg in stack).lower()
if FILE_EXT.search(key) or key in CFG_SKIP:
continue
cfg_mentions.setdefault(key, set()).add(relname)
else:
in_fence = False
for line in text.splitlines():
stripped = line.lstrip()
if stripped.startswith("```"):
in_fence = not in_fence
continue
if not in_fence:
for span in re.findall(r"`([^`\n]+)`", line):
for m in cfg_pat.finditer("`" + span + "`"):
key = m.group(1)
if FILE_EXT.search(key) or key in CFG_SKIP:
continue
cfg_mentions.setdefault(key, set()).add(relname)
cfg_missing = sorted(k for k in cfg_mentions if not key_is_real(k))
print()
print("=== CONFIG: referenced but NOT in upstream DEFAULT_CONFIG (%d) ==="
% len(cfg_missing))
for k in cfg_missing:
print(" %-34s -> %s" % (k, sorted(cfg_mentions[k])[:4]))
drift_count += len(cfg_missing)
print("Config surface checked: %d real paths (+%d wildcards), %d referenced"
% (len(exact), len(wildcards), len(cfg_mentions)))
print()
if missing:
print("DRIFT DETECTED:", drift_count, "non-existent reference(s)")
if args.exit_on_drift:
return 1
elif drift_count:
print("DRIFT DETECTED:", drift_count, "non-existent reference(s)")
if args.exit_on_drift:
return 1
else:
print("CLEAN: all referenced subcommands exist.")
return 0
if __name__ == "__main__":
sys.exit(main())