@ssheleg/agent-stack 0.7.2 → 0.9.0

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.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env python3
2
+ """Mechanical half of an agent-system audit.
3
+
4
+ Finds only what is visible WITHOUT understanding intent, and prints what it cannot see —
5
+ because a scanner that goes quiet is reporting its own blindness, and an audit that stops
6
+ at a silent scanner has audited the scanner.
7
+
8
+ python3 audit_agent.py <path> human-readable
9
+ python3 audit_agent.py <path> --json machine-readable
10
+ python3 audit_agent.py --self-test plant each defect, require each to be found
11
+
12
+ Zero dependencies. Python 3.9+.
13
+
14
+ Design rule, and the reason this file is short: every detector is CONSERVATIVE. A false
15
+ positive costs more than a miss here, because an audit report that cries wolf is discarded
16
+ whole — and the seven tracks in `references/audit.md` cover by hand everything this cannot
17
+ reach. Detectors therefore require corroboration (the file must look agent-related) and
18
+ each finding carries `file:line` so a human can disagree with it in one click.
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+
27
+ # A file is "agent-related" only if it shows two independent signs. One is a coincidence:
28
+ # plenty of code says "message" or "prompt" without being an agent loop.
29
+ AGENTISH = [
30
+ re.compile(r"\btool[_ ]?call", re.I),
31
+ re.compile(r"\btools\s*=|\"tools\"\s*:|'tools'\s*:"),
32
+ re.compile(r"\bsystem[_ ]?prompt", re.I),
33
+ re.compile(r"\b(anthropic|openai|litellm|langchain|langgraph|bedrock|mistral)\b", re.I),
34
+ re.compile(r"\bfunction[_ ]?call", re.I),
35
+ re.compile(r"\bmessages\s*=\s*\[|\"messages\"\s*:"),
36
+ ]
37
+ CODE_EXT = {".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"}
38
+ SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
39
+ ".next", "target", "vendor", ".tox", "site-packages"}
40
+ MAX_BYTES = 400_000 # a generated bundle is not worth reading, and skews everything
41
+
42
+ # Model ids that are usually hardcoded by accident. Deliberately not exhaustive: this is a
43
+ # smell detector, and the finding says "pin it deliberately", not "this id is wrong".
44
+ MODEL_LITERAL = re.compile(
45
+ r"[\"']((?:claude|gpt|gemini|llama|mistral|deepseek|qwen)[-\w.]*\d[\w.-]*)[\"']", re.I)
46
+
47
+ FINDINGS = []
48
+
49
+
50
+ def add(kind, path, line, detail, fix):
51
+ FINDINGS.append({"check": kind, "file": path, "line": line, "detail": detail, "fix": fix})
52
+
53
+
54
+ def agentish(text):
55
+ return sum(1 for p in AGENTISH if p.search(text)) >= 2
56
+
57
+
58
+ def walk(root):
59
+ for dirpath, dirnames, filenames in os.walk(root):
60
+ # Skip a virtualenv by its MARKER, not by its name. `venv`/`.venv` in SKIP_DIRS
61
+ # only catches the conventional names; a real repository met during testing used
62
+ # `myenv/`, holding 4249 of its 4261 code files, and was excluded only because
63
+ # `site-packages` happened to be listed too. Right by accident is not right.
64
+ dirnames[:] = [d for d in dirnames
65
+ if d not in SKIP_DIRS and not d.startswith(".")
66
+ and not os.path.exists(os.path.join(dirpath, d, "pyvenv.cfg"))]
67
+ for fn in filenames:
68
+ if os.path.splitext(fn)[1] not in CODE_EXT:
69
+ continue
70
+ full = os.path.join(dirpath, fn)
71
+ try:
72
+ if os.path.getsize(full) > MAX_BYTES:
73
+ continue
74
+ with open(full, encoding="utf-8", errors="replace") as fh:
75
+ text = fh.read()
76
+ except OSError:
77
+ continue
78
+ yield os.path.relpath(full, root), text
79
+
80
+
81
+ # ------------------------------------------------------------------ detectors
82
+
83
+ def check_unbounded_loop(rel, text, lines):
84
+ """`while True` in an agent file with no visible iteration bound.
85
+
86
+ Conservative twice over: the file must be agent-related, AND the file must not mention
87
+ any bound at all. A loop with `max_iter` somewhere else in the file is left alone.
88
+ """
89
+ if re.search(r"max[_ ]?iter|max[_ ]?steps|max[_ ]?turns|iteration_limit|for\s+\w+\s+in\s+range\(",
90
+ text, re.I):
91
+ return
92
+ for i, l in enumerate(lines, 1):
93
+ if re.search(r"^\s*while\s+(True|true|1)\s*[:)]|^\s*while\s*\(\s*true\s*\)", l):
94
+ add("unbounded-loop", rel, i,
95
+ "`while True` in an agent file with no iteration bound anywhere in it",
96
+ "Add a max-iteration guard that composes a partial answer at the bound, "
97
+ "rather than returning nothing")
98
+
99
+
100
+ def check_tool_without_description(rel, text, lines):
101
+ """A tool declared with an empty or missing description.
102
+
103
+ Only fires on an explicit empty string — a missing key is too easy to get wrong across
104
+ frameworks, and a wrong finding here is worse than a missed one.
105
+ """
106
+ for i, l in enumerate(lines, 1):
107
+ if re.search(r"[\"']description[\"']\s*:\s*[\"']\s*[\"']", l) or \
108
+ re.search(r"\bdescription\s*=\s*[\"']\s*[\"']", l):
109
+ add("tool-no-description", rel, i,
110
+ "a tool description is the empty string",
111
+ "Describe WHEN and WHY to use it, and name the neighbouring tool it is "
112
+ "confused with — the highest-leverage sentence in a tool definition")
113
+
114
+
115
+ def check_swallowed_error(rel, text, lines):
116
+ """An exception caught and discarded inside an agent file."""
117
+ for i, l in enumerate(lines, 1):
118
+ nxt = lines[i] if i < len(lines) else ""
119
+ if re.search(r"^\s*except[^\n]*:\s*$", l) and re.search(r"^\s*pass\s*$", nxt):
120
+ add("swallowed-error", rel, i,
121
+ "`except: pass` — the failure is invisible to the loop and to the model",
122
+ "Return an error the agent can act on; a tool error is a turn in the "
123
+ "conversation, not a silence")
124
+ if re.search(r"catch\s*\([^)]*\)\s*\{\s*\}", l):
125
+ add("swallowed-error", rel, i,
126
+ "empty `catch` block — the failure is discarded",
127
+ "Surface it to the loop; an error that teaches the next attempt costs "
128
+ "nothing extra")
129
+
130
+
131
+ def check_no_timeout(rel, text, lines):
132
+ """An outbound HTTP call with no timeout, in an agent file."""
133
+ for i, l in enumerate(lines, 1):
134
+ if re.search(r"\brequests\.(get|post|put|patch|delete)\s*\(", l) and "timeout" not in l:
135
+ add("no-timeout", rel, i,
136
+ "`requests` call with no `timeout=` — a hung provider hangs the agent",
137
+ "Set an explicit timeout on every external call, and decide what the loop "
138
+ "does when it fires")
139
+ if re.search(r"\burllib\.request\.urlopen\s*\(", l) and "timeout" not in l:
140
+ add("no-timeout", rel, i, "`urlopen` with no `timeout=`",
141
+ "Set an explicit timeout on every external call")
142
+
143
+
144
+ def check_hardcoded_model(rel, text, lines):
145
+ """A model id as a literal, in more than one place — the smell is duplication."""
146
+ hits = []
147
+ for i, l in enumerate(lines, 1):
148
+ if l.lstrip().startswith(("#", "//", "*")):
149
+ continue
150
+ m = MODEL_LITERAL.search(l)
151
+ if m:
152
+ hits.append((i, m.group(1)))
153
+ if len(hits) >= 2:
154
+ i, name = hits[0]
155
+ add("hardcoded-model", rel, i,
156
+ f"model id {name!r} appears as a literal {len(hits)}× in this file",
157
+ "Resolve the model from configuration at one boundary; three levels — request, "
158
+ "tenant, system default — is the shape that bills correctly")
159
+
160
+
161
+ CHECKS = [check_unbounded_loop, check_tool_without_description, check_swallowed_error,
162
+ check_no_timeout, check_hardcoded_model]
163
+
164
+ # What no static pass can reach. Printed every run, never suppressed.
165
+ BLIND = [
166
+ "whether the SYSTEM PROMPT is at the right altitude — or whether it is in this repo at all",
167
+ "whether two tool descriptions actually distinguish themselves to a model",
168
+ "whether the workflow/agent choice was made deliberately or defaulted to an agent",
169
+ "whether retries and fallbacks MULTIPLY (three providers x three retries is nine calls)",
170
+ "whether compaction preserves decisions and open questions, or keeps the discussion",
171
+ "whether tool output is treated as untrusted input",
172
+ "whether evals exist, judge trajectories, and are calibrated",
173
+ "whether an audit row could prove a control was applied (policy version)",
174
+ "everything in a language this pass does not read, and everything in configuration",
175
+ ]
176
+
177
+
178
+ def scan(root):
179
+ seen_files = considered = 0
180
+ for rel, text in walk(root):
181
+ considered += 1
182
+ if not agentish(text):
183
+ continue
184
+ seen_files += 1
185
+ lines = text.splitlines()
186
+ for c in CHECKS:
187
+ c(rel, text, lines)
188
+ return seen_files, considered
189
+
190
+
191
+ def report_text(root, seen, considered):
192
+ # The denominator is not decoration. "read: 1" alone looks like a broken pass; "1 of
193
+ # 4261" says the repository is mostly not an agent, which is a finding in itself when
194
+ # somebody called it one.
195
+ out = [f"agent-audit: {root}",
196
+ f" code files considered: {considered}",
197
+ f" of those, agent-related: {seen}"]
198
+ if not seen:
199
+ out.append(" NOTHING READ — no file showed two independent signs of an agent loop.")
200
+ out.append(" That is a fact about this pass, not about the system. Check the path,")
201
+ out.append(" and whether the agent lives in a language or a config this cannot read.")
202
+ out.append("")
203
+ if FINDINGS:
204
+ out.append(f"FINDINGS ({len(FINDINGS)}) — each is a smell with a location, not a verdict:")
205
+ for f in FINDINGS:
206
+ out.append(f" {f['file']}:{f['line']} [{f['check']}]")
207
+ out.append(f" {f['detail']}")
208
+ out.append(f" fix: {f['fix']}")
209
+ else:
210
+ out.append("FINDINGS (0) — nothing mechanically visible.")
211
+ out.append("")
212
+ out.append("THIS PASS CANNOT SEE — the manual half of the audit, and it is the larger half:")
213
+ for b in BLIND:
214
+ out.append(f" - {b}")
215
+ out.append("")
216
+ out.append("Walk the seven tracks in references/audit.md. Silence above is not a pass.")
217
+ return "\n".join(out)
218
+
219
+
220
+ def self_test():
221
+ """Plant each defect and require the matching check to fire.
222
+
223
+ A detector nobody has watched fire is not evidence that it works, and every plant
224
+ asserts it changed something so a reworded fixture fails HERE rather than reporting a
225
+ healthy checker as broken.
226
+ """
227
+ import tempfile
228
+ header = ("import requests\n"
229
+ "system_prompt = 'x'\n"
230
+ "tools = [{'name': 't', 'description': 'does a thing'}]\n"
231
+ "messages = []\n")
232
+ cases = {
233
+ "unbounded-loop": header + "while True:\n pass\n",
234
+ "tool-no-description": header + "T = [{'name': 'a', 'description': ''}]\n",
235
+ "swallowed-error": header + "try:\n x = 1\nexcept Exception:\n pass\n",
236
+ "no-timeout": header + "r = requests.get('https://example.com')\n",
237
+ "hardcoded-model": header + "a = 'claude-opus-4'\nb = 'claude-opus-4'\n",
238
+ }
239
+ failures = 0
240
+ for kind, body in cases.items():
241
+ FINDINGS.clear()
242
+ with tempfile.TemporaryDirectory() as d:
243
+ p = os.path.join(d, "agent.py")
244
+ with open(p, "w", encoding="utf-8") as fh:
245
+ fh.write(body)
246
+ assert agentish(body), f"PLANT DID NOT LAND: fixture for {kind} is not agent-related"
247
+ scan(d)
248
+ got = {f["check"] for f in FINDINGS}
249
+ if kind in got:
250
+ print(f" OK {kind}: detected")
251
+ else:
252
+ print(f" FAIL {kind}: NOT detected (found {sorted(got) or 'nothing'})")
253
+ failures += 1
254
+ # and a clean file must produce nothing, or every finding above is noise
255
+ FINDINGS.clear()
256
+ with tempfile.TemporaryDirectory() as d:
257
+ with open(os.path.join(d, "agent.py"), "w", encoding="utf-8") as fh:
258
+ fh.write(header + "for _ in range(10):\n pass\n"
259
+ "r = requests.get('https://example.com', timeout=5)\n")
260
+ scan(d)
261
+ if FINDINGS:
262
+ print(f" FAIL clean file produced {len(FINDINGS)} finding(s): "
263
+ f"{[f['check'] for f in FINDINGS]}")
264
+ failures += 1
265
+ else:
266
+ print(" OK clean file: silent")
267
+ print(f"\nself-test: {len(cases) + 1 - failures}/{len(cases) + 1} passed")
268
+ return 1 if failures else 0
269
+
270
+
271
+ def main(argv):
272
+ ap = argparse.ArgumentParser(description="Mechanical half of an agent-system audit.")
273
+ ap.add_argument("path", nargs="?", default=".")
274
+ ap.add_argument("--json", action="store_true")
275
+ ap.add_argument("--self-test", action="store_true")
276
+ a = ap.parse_args(argv)
277
+
278
+ if a.self_test:
279
+ return self_test()
280
+
281
+ root = os.path.abspath(a.path)
282
+ if not os.path.isdir(root):
283
+ print(f"error: {a.path} is not a directory", file=sys.stderr)
284
+ return 2
285
+ seen, considered = scan(root)
286
+ if a.json:
287
+ print(json.dumps({"root": root, "files_considered": considered,
288
+ "files_agent_related": seen, "findings": FINDINGS,
289
+ "cannot_see": BLIND}, indent=2))
290
+ else:
291
+ print(report_text(root, seen, considered))
292
+ # Findings are smells, not failures: exit 0 so this composes in a pipeline, and let the
293
+ # human decide. A non-zero exit here would turn an audit into a gate it was never
294
+ # calibrated to be.
295
+ return 0
296
+
297
+
298
+ if __name__ == "__main__":
299
+ sys.exit(main(sys.argv[1:]))