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.
- agentmemorytaintgap/__init__.py +7 -0
- agentmemorytaintgap/cli.py +100 -0
- agentmemorytaintgap/scanner.py +442 -0
- agentmemorytaintgap-0.1.0.dist-info/METADATA +158 -0
- agentmemorytaintgap-0.1.0.dist-info/RECORD +8 -0
- agentmemorytaintgap-0.1.0.dist-info/WHEEL +4 -0
- agentmemorytaintgap-0.1.0.dist-info/entry_points.txt +2 -0
- agentmemorytaintgap-0.1.0.dist-info/licenses/LICENSE +21 -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,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.
|