agentmemorytaintgap 0.1.0__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.
@@ -0,0 +1,7 @@
1
+ """agentmemorytaintgap - flag untrusted content persisted into agent long-term memory."""
2
+
3
+ from .scanner import scan_file, scan_source, Finding
4
+
5
+ __all__ = ["scan_file", "scan_source", "Finding"]
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,100 @@
1
+ """agentmemorytaintgap command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .scanner import scan_file
11
+
12
+
13
+ def _iter_python_files(paths: list[str]) -> list[Path]:
14
+ files: list[Path] = []
15
+ for raw in paths:
16
+ p = Path(raw)
17
+ if p.is_dir():
18
+ files.extend(sorted(p.rglob("*.py")))
19
+ elif p.suffix == ".py":
20
+ files.append(p)
21
+ return files
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ prog="agentmemorytaintgap",
27
+ description=(
28
+ "Flag AI agent memory-write calls that persist untrusted tool/"
29
+ "external/user content into long-term memory with no sanitization "
30
+ "or provenance marker (agent memory poisoning)."
31
+ ),
32
+ )
33
+ parser.add_argument(
34
+ "paths", nargs="+", help="Python files or directories to scan"
35
+ )
36
+ parser.add_argument(
37
+ "--json",
38
+ action="store_true",
39
+ help="Emit machine-readable JSON instead of human-readable text.",
40
+ )
41
+ parser.add_argument(
42
+ "--strict",
43
+ action="store_true",
44
+ help="Also fail (exit 1) when only AT002 warnings are present.",
45
+ )
46
+ return parser
47
+
48
+
49
+ def main(argv: list[str] | None = None) -> int:
50
+ parser = build_parser()
51
+ args = parser.parse_args(argv)
52
+
53
+ files = _iter_python_files(args.paths)
54
+ if not files:
55
+ print("agentmemorytaintgap: no Python files found", file=sys.stderr)
56
+ return 2
57
+
58
+ all_findings = []
59
+ had_error = False
60
+ for f in files:
61
+ try:
62
+ findings = scan_file(f)
63
+ except SyntaxError as exc:
64
+ print(f"agentmemorytaintgap: {f}: syntax error: {exc}", file=sys.stderr)
65
+ had_error = True
66
+ continue
67
+ all_findings.extend(findings)
68
+
69
+ if had_error:
70
+ return 2
71
+
72
+ blockers = [f for f in all_findings if f.rule == "AT001"]
73
+ warnings = [f for f in all_findings if f.rule == "AT002"]
74
+
75
+ if args.json:
76
+ payload = {
77
+ "findings": [f.to_dict() for f in all_findings],
78
+ "blockers": len(blockers),
79
+ "warnings": len(warnings),
80
+ "files_scanned": len(files),
81
+ }
82
+ print(json.dumps(payload, indent=2))
83
+ else:
84
+ for finding in all_findings:
85
+ print(str(finding))
86
+ if all_findings:
87
+ print(
88
+ f"\n{len(files)} file(s) scanned · {len(blockers)} blocker(s) · "
89
+ f"{len(warnings)} warning(s)"
90
+ )
91
+
92
+ if blockers:
93
+ return 1
94
+ if args.strict and warnings:
95
+ return 1
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__":
100
+ raise SystemExit(main())
@@ -0,0 +1,442 @@
1
+ """agentmemorytaintgap scanner.
2
+
3
+ Static, AST-only detector for a specific AI-agent memory-safety gap:
4
+ a *memory-write* call (something that persists content into a store that
5
+ will be read back and fed into a FUTURE prompt as the agent's own trusted
6
+ memory) that stores raw content sourced from an untrusted origin (a tool
7
+ result, a raw user message, or external fetched content) with no
8
+ provenance marker or sanitization step in between.
9
+
10
+ This is deliberately a pragmatic, heuristic, single-hop, same-function-scope
11
+ analysis — NOT full data-flow / taint tracking. See DETAILS.md for the exact
12
+ scope and the honest false-positive/false-negative surface that results.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Recognition heuristics (all documented in DETAILS.md / README.md)
23
+ # ---------------------------------------------------------------------------
24
+
25
+ # Method names, called on *any* object, that are recognized memory-write
26
+ # call shapes for common agent-memory frameworks (LangChain-shaped) or a
27
+ # generic vector-store-as-memory pattern.
28
+ MEMORY_WRITE_METHODS = {
29
+ "save_context",
30
+ "add_message",
31
+ "add_user_message",
32
+ "add_ai_message",
33
+ }
34
+
35
+ # Generic vector-store-as-memory methods. Only counted as a memory write
36
+ # when called on a variable whose name matches MEMORY_VAR_HINTS below.
37
+ VECTOR_STORE_METHODS = {"add", "upsert"}
38
+
39
+ # Variable-name substrings that mark an object as a long-term memory /
40
+ # vector store (heuristic naming signal, not a type check).
41
+ MEMORY_VAR_HINTS = ("memory", "mem_store", "long_term")
42
+
43
+ # Free function / bound method names that look like a custom "remember this"
44
+ # helper.
45
+ CUSTOM_MEMORY_FUNCS = {"remember", "store_memory", "save_memory"}
46
+
47
+ # Attribute chains that look like a LangChain memory object's nested
48
+ # chat_memory accessor, e.g. `memory.chat_memory.add_user_message(...)`.
49
+ CHAT_MEMORY_ATTR = "chat_memory"
50
+
51
+ # Function/method name substrings that look like a tool invocation.
52
+ TOOL_CALL_METHODS = {"run", "invoke"}
53
+ TOOL_VAR_HINTS = ("tool",)
54
+ TOOL_DECORATOR_NAMES = {"tool"}
55
+
56
+ # Function/method name substrings that look like the LLM's own generation
57
+ # call (its output is the model's own words -> treated as trusted).
58
+ LLM_CALL_METHODS = {"invoke", "generate", "predict", "run", "__call__"}
59
+ LLM_VAR_HINTS = ("llm", "model", "chat")
60
+
61
+ # Requests/HTTP-shaped external fetch calls.
62
+ HTTP_MODULES = {"requests", "httpx", "aiohttp"}
63
+ HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
64
+ FETCH_NAMES = {"fetch"}
65
+
66
+ # Function names that look like sanitization / provenance tagging.
67
+ SANITIZE_NAME_SUBSTRINGS = ("sanitize", "clean", "validate", "tag_source", "mark_untrusted")
68
+
69
+ # Parameter names that look like raw user input.
70
+ USER_INPUT_PARAM_HINTS = ("user_input", "user_message", "raw_input", "user_query", "message")
71
+
72
+
73
+ @dataclass
74
+ class Finding:
75
+ rule: str
76
+ severity: str # "blocker" | "warning"
77
+ path: str
78
+ line: int
79
+ col: int
80
+ message: str
81
+ source_line: str = ""
82
+
83
+ def __str__(self) -> str:
84
+ tag = "BLOCKER" if self.severity == "blocker" else "WARNING"
85
+ loc = f"{self.path}:{self.line}:{self.col}"
86
+ out = f"{tag} {self.rule} {loc} {self.message}"
87
+ if self.source_line:
88
+ out += f"\n {self.source_line.strip()}"
89
+ return out
90
+
91
+ def to_dict(self) -> dict:
92
+ return {
93
+ "rule": self.rule,
94
+ "severity": self.severity,
95
+ "path": self.path,
96
+ "line": self.line,
97
+ "col": self.col,
98
+ "message": self.message,
99
+ "source_line": self.source_line,
100
+ }
101
+
102
+
103
+ def _name_of(node: ast.AST) -> str | None:
104
+ """Best-effort simple name for a Name/Attribute node's base identifier."""
105
+ if isinstance(node, ast.Name):
106
+ return node.id
107
+ if isinstance(node, ast.Attribute):
108
+ return _name_of(node.value)
109
+ return None
110
+
111
+
112
+ def _call_func_name(call: ast.Call) -> str | None:
113
+ """The final attribute/name of the call target, e.g. 'add' for x.y.add(...)."""
114
+ func = call.func
115
+ if isinstance(func, ast.Name):
116
+ return func.id
117
+ if isinstance(func, ast.Attribute):
118
+ return func.attr
119
+ return None
120
+
121
+
122
+ def _decorator_names(func_node: ast.AST) -> set[str]:
123
+ names = set()
124
+ decorators = getattr(func_node, "decorator_list", [])
125
+ for dec in decorators:
126
+ target = dec
127
+ if isinstance(target, ast.Call):
128
+ target = target.func
129
+ name = _call_func_name_or_name(target)
130
+ if name:
131
+ names.add(name)
132
+ return names
133
+
134
+
135
+ def _call_func_name_or_name(node: ast.AST) -> str | None:
136
+ if isinstance(node, ast.Name):
137
+ return node.id
138
+ if isinstance(node, ast.Attribute):
139
+ return node.attr
140
+ return None
141
+
142
+
143
+ def _is_tool_call(call: ast.Call) -> bool:
144
+ """Value came from a tool invocation: `.run()`/`.invoke()` on a
145
+ tool-named variable, or from calling a name that's a `@tool`-decorated
146
+ function elsewhere in the module (handled by caller with tool_func_names)."""
147
+ func = call.func
148
+ if isinstance(func, ast.Attribute) and func.attr in TOOL_CALL_METHODS:
149
+ base_name = _name_of(func.value)
150
+ if base_name and any(hint in base_name.lower() for hint in TOOL_VAR_HINTS):
151
+ return True
152
+ return False
153
+
154
+
155
+ def _is_llm_call(call: ast.Call) -> bool:
156
+ """Value came from the LLM's own generation call -> trusted/safe."""
157
+ func = call.func
158
+ if isinstance(func, ast.Attribute) and func.attr in LLM_CALL_METHODS:
159
+ base_name = _name_of(func.value)
160
+ if base_name and any(hint in base_name.lower() for hint in LLM_VAR_HINTS):
161
+ return True
162
+ return False
163
+
164
+
165
+ def _is_http_fetch_call(call: ast.Call) -> bool:
166
+ func = call.func
167
+ if isinstance(func, ast.Attribute) and func.attr in HTTP_METHODS:
168
+ base_name = _name_of(func.value)
169
+ if base_name and base_name.lower() in HTTP_MODULES:
170
+ return True
171
+ if isinstance(func, ast.Name) and func.id in FETCH_NAMES:
172
+ return True
173
+ return False
174
+
175
+
176
+ def _is_sanitize_call_name(name: str | None) -> bool:
177
+ if not name:
178
+ return False
179
+ lowered = name.lower()
180
+ return any(sub in lowered for sub in SANITIZE_NAME_SUBSTRINGS)
181
+
182
+
183
+ class _FunctionAnalyzer:
184
+ """Analyzes a single function body for memory-write calls and tries to
185
+ trace each write's stored value back to a trusted/untrusted/unknown
186
+ origin, single-hop, within this function's own scope only."""
187
+
188
+ def __init__(self, func_node, path: str, source_lines: list[str], tool_func_names: set[str]):
189
+ self.func_node = func_node
190
+ self.path = path
191
+ self.source_lines = source_lines
192
+ self.tool_func_names = tool_func_names
193
+ self.findings: list[Finding] = []
194
+ # var name -> "untrusted" | "trusted" | "unknown" (last assignment wins,
195
+ # this is intentionally simple/local, not a full CFG).
196
+ self.origins: dict[str, str] = {}
197
+ self.sanitized_vars: set[str] = set()
198
+ self.param_names = {a.arg for a in func_node.args.args}
199
+
200
+ def analyze(self) -> list[Finding]:
201
+ for stmt in ast.walk(self.func_node):
202
+ if isinstance(stmt, (ast.Assign, ast.AnnAssign)) and stmt.value is not None:
203
+ self._record_assignment(stmt)
204
+ elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
205
+ self._maybe_record_sanitize_call(stmt.value)
206
+
207
+ for stmt in ast.walk(self.func_node):
208
+ if isinstance(stmt, ast.Call):
209
+ self._check_memory_write(stmt)
210
+
211
+ return self.findings
212
+
213
+ def _record_assignment(self, stmt) -> None:
214
+ targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
215
+ value = stmt.value
216
+ origin = self._classify_value_origin(value)
217
+ for t in targets:
218
+ name = _name_of(t)
219
+ if name:
220
+ self.origins[name] = origin
221
+
222
+ def _maybe_record_sanitize_call(self, call: ast.Call) -> None:
223
+ name = _call_func_name(call)
224
+ if _is_sanitize_call_name(name):
225
+ for arg in call.args:
226
+ arg_name = _name_of(arg)
227
+ if arg_name:
228
+ self.sanitized_vars.add(arg_name)
229
+
230
+ def _classify_value_origin(self, value: ast.AST) -> str:
231
+ """Classify a single expression's origin as trusted/untrusted/unknown,
232
+ single hop (does not recurse into further variable lookups)."""
233
+ if isinstance(value, ast.Call):
234
+ name = _call_func_name(value)
235
+ if _is_sanitize_call_name(name):
236
+ return "trusted"
237
+ if _is_llm_call(value):
238
+ return "trusted"
239
+ if _is_tool_call(value):
240
+ return "untrusted"
241
+ if _is_http_fetch_call(value):
242
+ return "untrusted"
243
+ if name and name in self.tool_func_names:
244
+ return "untrusted"
245
+ return "unknown"
246
+ if isinstance(value, ast.Name):
247
+ if value.id in self.origins:
248
+ return self.origins[value.id]
249
+ if value.id in self.param_names:
250
+ if any(hint in value.id.lower() for hint in USER_INPUT_PARAM_HINTS):
251
+ return "untrusted"
252
+ return "unknown"
253
+ return "unknown"
254
+ if isinstance(value, ast.Attribute):
255
+ # e.g. response.text -> resolve the base variable's origin.
256
+ base = _name_of(value)
257
+ if base is not None:
258
+ return self._resolve_name_origin(base)
259
+ return "unknown"
260
+ if isinstance(value, ast.JoinedStr):
261
+ # f-string: inspect embedded values for a clearly untrusted piece.
262
+ classes = set()
263
+ for part in value.values:
264
+ if isinstance(part, ast.FormattedValue):
265
+ classes.add(self._classify_value_origin(part.value))
266
+ if "untrusted" in classes:
267
+ return "untrusted"
268
+ if classes == {"trusted"}:
269
+ return "trusted"
270
+ return "unknown"
271
+ return "unknown"
272
+
273
+ def _resolve_name_origin(self, name: str) -> str:
274
+ if name in self.origins:
275
+ return self.origins[name]
276
+ if name in self.param_names:
277
+ if any(hint in name.lower() for hint in USER_INPUT_PARAM_HINTS):
278
+ return "untrusted"
279
+ return "unknown"
280
+ return "unknown"
281
+
282
+ def _resolve_arg_origin(self, arg: ast.AST) -> str:
283
+ """Resolve a memory-write call's argument expression to an origin,
284
+ single hop: either classify it directly, or if it's a bare Name,
285
+ look up how it was last assigned in this function."""
286
+ if isinstance(arg, ast.Name):
287
+ return self._resolve_name_origin(arg.id)
288
+ return self._classify_value_origin(arg)
289
+
290
+ def _arg_var_names(self, arg: ast.AST) -> set[str]:
291
+ names = set()
292
+ for node in ast.walk(arg):
293
+ if isinstance(node, ast.Name):
294
+ names.add(node.id)
295
+ return names
296
+
297
+ def _all_call_args(self, call: ast.Call) -> list[ast.AST]:
298
+ """The content-bearing argument expression(s) of a memory-write call.
299
+
300
+ Only the *last* positional argument is treated as the stored content
301
+ (this matches e.g. `save_context(inputs, outputs)`, where `outputs`
302
+ is what gets remembered as the agent's own record; the leading
303
+ argument(s) are typically bookkeeping like the original input echo),
304
+ plus any keyword arguments. Dict-literal arguments are unwrapped to
305
+ their value expressions (so `{"output": tool_result}` yields
306
+ `tool_result`, not the dict itself)."""
307
+ raw: list[ast.AST] = []
308
+ if call.args:
309
+ raw.append(call.args[-1])
310
+ raw.extend(kw.value for kw in call.keywords)
311
+
312
+ leaves: list[ast.AST] = []
313
+ for expr in raw:
314
+ if isinstance(expr, ast.Dict):
315
+ leaves.extend(v for v in expr.values if v is not None)
316
+ else:
317
+ leaves.append(expr)
318
+ return leaves
319
+
320
+ def _is_memory_write_call(self, call: ast.Call) -> tuple[bool, list[ast.AST]]:
321
+ """Returns (is_memory_write, candidate content expressions) if this
322
+ call matches a recognized memory-write shape."""
323
+ func = call.func
324
+ if not isinstance(func, ast.Attribute):
325
+ # Free function call: custom memory pattern, e.g. remember(x)
326
+ if isinstance(func, ast.Name) and func.id in CUSTOM_MEMORY_FUNCS:
327
+ return True, self._all_call_args(call)
328
+ return False, []
329
+
330
+ method_name = func.attr
331
+
332
+ # LangChain-shaped .save_context(...) / .add_message(...) / etc,
333
+ # including nested .chat_memory.add_user_message(...).
334
+ if method_name in MEMORY_WRITE_METHODS:
335
+ return True, self._all_call_args(call)
336
+
337
+ # Custom memory pattern as a bound method: obj.remember(...) etc.
338
+ if method_name in CUSTOM_MEMORY_FUNCS:
339
+ return True, self._all_call_args(call)
340
+
341
+ # Generic vector-store-as-memory: .add(...)/.upsert(...) on a
342
+ # memory-hinted variable name.
343
+ if method_name in VECTOR_STORE_METHODS:
344
+ base_name = _name_of(func.value)
345
+ if base_name and any(hint in base_name.lower() for hint in MEMORY_VAR_HINTS):
346
+ return True, self._all_call_args(call)
347
+
348
+ return False, []
349
+
350
+ def _check_memory_write(self, call: ast.Call) -> None:
351
+ is_write, args = self._is_memory_write_call(call)
352
+ if not is_write:
353
+ return
354
+
355
+ line = call.lineno
356
+ col = call.col_offset
357
+ src_line = self.source_lines[line - 1] if 0 < line <= len(self.source_lines) else ""
358
+
359
+ if not args:
360
+ return # nothing to trace
361
+
362
+ all_arg_names: set[str] = set()
363
+ for arg in args:
364
+ all_arg_names |= self._arg_var_names(arg)
365
+ if all_arg_names & self.sanitized_vars:
366
+ return # short-circuit: sanitized/tagged before the write
367
+
368
+ origins = {self._resolve_arg_origin(arg) for arg in args}
369
+ if "untrusted" in origins:
370
+ origin = "untrusted"
371
+ elif "unknown" in origins:
372
+ origin = "unknown"
373
+ else:
374
+ origin = "trusted"
375
+
376
+ if origin == "untrusted":
377
+ self.findings.append(
378
+ Finding(
379
+ rule="AT001",
380
+ severity="blocker",
381
+ path=self.path,
382
+ line=line,
383
+ col=col,
384
+ message=(
385
+ "Memory-write call stores content traced back to an untrusted "
386
+ "origin (tool output / external fetch / raw user input) with no "
387
+ "sanitization or provenance-tagging call in this function "
388
+ "(agent memory poisoning risk)."
389
+ ),
390
+ source_line=src_line,
391
+ )
392
+ )
393
+ elif origin == "unknown":
394
+ self.findings.append(
395
+ Finding(
396
+ rule="AT002",
397
+ severity="warning",
398
+ path=self.path,
399
+ line=line,
400
+ col=col,
401
+ message=(
402
+ "Memory-write call stores a value whose origin could not be "
403
+ "confidently traced (parameter with no clear origin signal, or "
404
+ "a more-than-one-hop assignment chain) — worth a human glance."
405
+ ),
406
+ source_line=src_line,
407
+ )
408
+ )
409
+ # origin == "trusted" -> no finding (e.g. the LLM's own generated response)
410
+
411
+
412
+ def _collect_tool_func_names(tree: ast.AST) -> set[str]:
413
+ """Collect names of functions decorated with a recognizable @tool
414
+ decorator anywhere in the module, so calls to them elsewhere can be
415
+ classified as untrusted tool output."""
416
+ names = set()
417
+ for node in ast.walk(tree):
418
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
419
+ if TOOL_DECORATOR_NAMES & _decorator_names(node):
420
+ names.add(node.name)
421
+ return names
422
+
423
+
424
+ def scan_source(source: str, path: str = "<string>") -> list[Finding]:
425
+ tree = ast.parse(source, filename=path)
426
+ source_lines = source.splitlines()
427
+ tool_func_names = _collect_tool_func_names(tree)
428
+
429
+ findings: list[Finding] = []
430
+ for node in ast.walk(tree):
431
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
432
+ analyzer = _FunctionAnalyzer(node, path, source_lines, tool_func_names)
433
+ findings.extend(analyzer.analyze())
434
+
435
+ findings.sort(key=lambda f: (f.line, f.col))
436
+ return findings
437
+
438
+
439
+ def scan_file(path: str | Path) -> list[Finding]:
440
+ p = Path(path)
441
+ source = p.read_text(encoding="utf-8")
442
+ return scan_source(source, path=str(path))
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentmemorytaintgap
3
+ Version: 0.1.0
4
+ Summary: Flags AI agent code that persists untrusted tool/external/user content into long-term memory with no sanitization or provenance marker (agent memory poisoning).
5
+ Author: Jay
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: ai-agents,linter,llm-security,memory-poisoning,prompt-injection,static-analysis
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # agentmemorytaintgap
22
+
23
+ **Catch untrusted content being written into an AI agent's long-term memory as if it were a trusted fact.**
24
+
25
+ Prompt injection usually gets pictured as a single bad turn: a poisoned web
26
+ page or tool result sneaks into one prompt, the model says something wrong
27
+ once, and the blast radius ends there. Agent **memory** breaks that
28
+ assumption. A growing class of agent frameworks let the agent persist
29
+ content — a tool result, a summary, a "fact" — into a long-term memory store
30
+ that gets read back and fed into *every future prompt* as if it were the
31
+ agent's own trusted conclusion. If the content that gets written was actually
32
+ attacker-controlled (a malicious tool response, a crafted user message, a
33
+ poisoned web page), and nothing strips or tags it first, the poison survives
34
+ for the lifetime of that memory store — this is the "agent memory poisoning"
35
+ risk that's an increasingly-discussed, distinct branch of prompt injection in
36
+ 2026.
37
+
38
+ `agentmemorytaintgap` reads your source with Python's `ast` module — no
39
+ imports, no execution — and flags the memory-**write** call sites where that
40
+ can happen:
41
+
42
+ ```
43
+ $ agentmemorytaintgap agent/
44
+
45
+ BLOCKER AT001 agent/handler.py:3:4 Memory-write call stores content traced back to an untrusted origin (tool output / external fetch / raw user input) with no sanitization or provenance-tagging call in this function (agent memory poisoning risk).
46
+ agent_memory.save_context({"input": user_query}, {"output": tool_result})
47
+
48
+ 1 file(s) scanned · 1 blocker(s) · 0 warning(s)
49
+ ```
50
+
51
+ Exit code `1` on a blocker, so it drops straight into pre-commit or CI.
52
+
53
+ ## What it flags
54
+
55
+ | Rule | Severity | Fires when… |
56
+ | :--- | :--- | :--- |
57
+ | **AT001** | blocker | a memory-write call's stored value traces (single-hop, same function) back to a **provably untrusted** origin — tool output, an HTTP/fetch response, or a raw user-input parameter — with **no** sanitize/tag call on it anywhere in that function. |
58
+ | **AT002** | warning | a memory-write call's stored value origin **could not be confidently traced** either way (a bare parameter with no naming signal, or a longer assignment chain). Lower confidence — worth a human glance, not a confident blocker. |
59
+
60
+ The LLM's own generated response (e.g. `llm.invoke(...)`) is treated as
61
+ trusted and is never flagged — the concern here is specifically *external*
62
+ content being stored as if it were the agent's own conclusion.
63
+
64
+ ### Recognized memory-write shapes
65
+
66
+ - LangChain-shaped: `.save_context(...)`, `.chat_memory.add_message(...)` /
67
+ `.add_user_message(...)` / `.add_ai_message(...)`.
68
+ - Generic vector-store-as-memory: `.add(...)` / `.upsert(...)` called on a
69
+ variable whose name contains `memory`, `mem_store`, or `long_term` — there
70
+ is no single standard "agent memory" API the way there is for HTTP, so
71
+ this is a **naming heuristic**, documented honestly in `DETAILS.md`.
72
+ - Custom memory helpers: a call to `remember(...)`, `store_memory(...)`, or
73
+ `save_memory(...)`, as a free function or bound method.
74
+
75
+ ### Recognized untrusted origins
76
+
77
+ - A tool call: `.run(...)` / `.invoke(...)` on a variable named like `tool`,
78
+ or a call to a function decorated with a recognizable `@tool` decorator.
79
+ - An external fetch: `requests`/`httpx`/`aiohttp` `.get/.post/...(...)`, or a
80
+ call to a function literally named `fetch`.
81
+ - A raw user-input function parameter (named like `user_input`,
82
+ `user_message`, `raw_input`, `user_query`, or `message`).
83
+
84
+ ### Safe-marker short-circuit
85
+
86
+ If a call whose name contains `sanitize`, `clean`, `validate`, `tag_source`,
87
+ or `mark_untrusted` is applied to the value anywhere in the
88
+ same function before the memory write, the finding does not fire — the
89
+ short-circuit is deliberately generous, the same style as its sibling tools.
90
+
91
+ ## How it relates to echofence
92
+
93
+ `echofence` and `agentmemorytaintgap` are both prompt-injection-adjacent AST
94
+ linters, and they are **deliberately distinct, non-overlapping tools**:
95
+
96
+ - **[`echofence`](https://github.com/jay-tank/echofence) — input side, single
97
+ turn.** Flags untrusted *external* content reaching a live LLM **prompt**
98
+ directly — the indirect variant of OWASP LLM01. The risk window is one
99
+ request/response cycle.
100
+ - **`agentmemorytaintgap` — persistence side, every future turn.** Flags
101
+ untrusted content being **written into long-term memory** that will be
102
+ read back and replayed as trusted context across *every subsequent turn*,
103
+ potentially for the lifetime of the memory store. The artifact, the
104
+ timing, and the risk shape are different: a poisoned prompt affects one
105
+ answer; a poisoned memory write affects all future answers until someone
106
+ notices and purges the store.
107
+
108
+ See `DETAILS.md` for the full, honest comparison — including why this is not
109
+ just "echofence but for a different sink."
110
+
111
+ ## Install
112
+
113
+ ```bash
114
+ pip install agentmemorytaintgap
115
+ ```
116
+
117
+ ## Usage
118
+
119
+ ```bash
120
+ agentmemorytaintgap agent/ # scan a directory
121
+ agentmemorytaintgap memory_handler.py # scan a file
122
+ agentmemorytaintgap agent/ --strict # AT002 warnings fail the run too
123
+ agentmemorytaintgap agent/ --json # machine-readable output
124
+ ```
125
+
126
+ ### In CI
127
+
128
+ ```yaml
129
+ - run: pip install agentmemorytaintgap
130
+ - run: agentmemorytaintgap agent/ --strict
131
+ ```
132
+
133
+ Exit codes: `0` clean · `1` a blocker (AT001), or any finding under
134
+ `--strict` · `2` usage error.
135
+
136
+ ## Honest limitations
137
+
138
+ `agentmemorytaintgap` is a **pragmatic, heuristic, single-hop,
139
+ same-function-scope** analyzer — **not** full data-flow / taint analysis.
140
+ See `DETAILS.md` for the complete breakdown, but concretely:
141
+
142
+ - It only recognizes the memory-write shapes and naming conventions listed
143
+ above. A memory variable that doesn't contain `memory`/`mem_store`/
144
+ `long_term` in its name, or a tool call that doesn't match the recognized
145
+ `@tool`/`.run()`/`.invoke()` shapes, is invisible to v0.1.
146
+ - Tracing is single-hop and scoped to one function. A value laundered
147
+ through a helper function it doesn't look inside of, or passed across
148
+ functions before being written to memory, will not be traced.
149
+ - A sanitize/tag call anywhere in the function short-circuits the finding —
150
+ it trusts that the call actually does what its name implies; it does not
151
+ verify that.
152
+
153
+ Treat it as a fast reviewer that catches the obvious, high-value cases on
154
+ every PR, paired with human judgment for the rest.
155
+
156
+ ## License
157
+
158
+ MIT © Jay Tank
@@ -0,0 +1,8 @@
1
+ agentmemorytaintgap/__init__.py,sha256=k84Dy06UAx6Bws5aO_w2cKBNfqOc_xdmAuYxGVFRLGc,218
2
+ agentmemorytaintgap/cli.py,sha256=elXnd7L3y9x_dRhYk2Do5j7Yx5sEAivtlNu_abz_Mpg,2716
3
+ agentmemorytaintgap/scanner.py,sha256=lG4j9d1DGaeb6ZhS9fXOpwFCrH7KBrfyfnmE6BvNM70,16973
4
+ agentmemorytaintgap-0.1.0.dist-info/METADATA,sha256=ng025WARYHDmqt1wsplmvAVBCY4XlvSzEbb9PLAe2Zc,7253
5
+ agentmemorytaintgap-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ agentmemorytaintgap-0.1.0.dist-info/entry_points.txt,sha256=t4G_00wVvUpmJ7J7G_X6Ri5kcQuPTf258pHtMQ-tLwY,69
7
+ agentmemorytaintgap-0.1.0.dist-info/licenses/LICENSE,sha256=xEsRvzJ-7Nbqa9ppe87ZOX9Xgmc2CJ7HOEFGRa0rcpU,1060
8
+ agentmemorytaintgap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentmemorytaintgap = agentmemorytaintgap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.