agentmemorytaintgap 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +22 -0
- agentmemorytaintgap-0.1.0/.gitignore +6 -0
- agentmemorytaintgap-0.1.0/DETAILS.md +193 -0
- agentmemorytaintgap-0.1.0/LICENSE +21 -0
- agentmemorytaintgap-0.1.0/PKG-INFO +158 -0
- agentmemorytaintgap-0.1.0/README.md +138 -0
- agentmemorytaintgap-0.1.0/docs/USAGE.md +93 -0
- agentmemorytaintgap-0.1.0/examples/README.md +16 -0
- agentmemorytaintgap-0.1.0/examples/ambiguous_origin.py +5 -0
- agentmemorytaintgap-0.1.0/examples/clean_llm_response.py +7 -0
- agentmemorytaintgap-0.1.0/examples/clean_sanitized.py +8 -0
- agentmemorytaintgap-0.1.0/examples/unsafe_tool_output.py +13 -0
- agentmemorytaintgap-0.1.0/pyproject.toml +32 -0
- agentmemorytaintgap-0.1.0/pytest.ini +1 -0
- agentmemorytaintgap-0.1.0/src/agentmemorytaintgap/__init__.py +7 -0
- agentmemorytaintgap-0.1.0/src/agentmemorytaintgap/cli.py +100 -0
- agentmemorytaintgap-0.1.0/src/agentmemorytaintgap/scanner.py +442 -0
- agentmemorytaintgap-0.1.0/tests/__init__.py +0 -0
- agentmemorytaintgap-0.1.0/tests/test_cli.py +99 -0
- agentmemorytaintgap-0.1.0/tests/test_scanner.py +170 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install package with dev extras
|
|
20
|
+
run: pip install -e ".[dev]"
|
|
21
|
+
- name: Run tests
|
|
22
|
+
run: pytest tests/ -v
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# agentmemorytaintgap — DETAILS
|
|
2
|
+
|
|
3
|
+
## The gap this fills
|
|
4
|
+
|
|
5
|
+
Prompt injection is usually discussed as a single-turn problem: untrusted
|
|
6
|
+
content (a scraped page, a tool result, a crafted user message) gets built
|
|
7
|
+
into one LLM prompt, and the model's *response* to that one call is what's
|
|
8
|
+
at risk. That's the shape tools like `echofence` cover.
|
|
9
|
+
|
|
10
|
+
Agent frameworks with **persistent/long-term memory** introduce a different
|
|
11
|
+
failure mode. When an agent explicitly *writes* content into a memory store
|
|
12
|
+
— because the framework encourages "remembering" facts, summaries, or past
|
|
13
|
+
tool results across sessions — that write becomes a trust boundary crossing
|
|
14
|
+
of its own. If the content being written was itself untrusted (attacker- or
|
|
15
|
+
externally-controlled), and it gets stored with no marker distinguishing it
|
|
16
|
+
from the agent's own reasoning, every *future* prompt that reads that memory
|
|
17
|
+
back in treats it as ground truth. The attack surface is not "one bad
|
|
18
|
+
response" — it's "every future response until the memory store is purged or
|
|
19
|
+
the poisoned entry is found."
|
|
20
|
+
|
|
21
|
+
This is a real, currently-discussed, and distinct risk category. As of
|
|
22
|
+
research current through 2026, multiple papers explicitly separate memory
|
|
23
|
+
poisoning from ordinary (single-turn) prompt injection on exactly this
|
|
24
|
+
timing/persistence axis:
|
|
25
|
+
|
|
26
|
+
- *"From Untrusted Input to Trusted Memory: A Systematic Study of Memory
|
|
27
|
+
Poisoning Attacks in LLM Agents"* (arXiv 2606.04329) — memory poisoning
|
|
28
|
+
lets an adversary insert malicious information into an agent's long-term
|
|
29
|
+
memory, which then persistently influences future behavior without
|
|
30
|
+
further attacker involvement, explicitly distinguishing it from
|
|
31
|
+
traditional single-session prompt injection.
|
|
32
|
+
- *"A Survey on Long-Term Memory Security in LLM Agents: Attacks, Defenses,
|
|
33
|
+
and Governance Across the Memory Lifecycle"* (arXiv 2604.16548).
|
|
34
|
+
- *"Securing LLM-Agent Long-Term Memory Against Poisoning..."*
|
|
35
|
+
(arXiv 2606.24322).
|
|
36
|
+
- Industry coverage: OWASP's "Agent Memory Guard" guidance, and vendor posts
|
|
37
|
+
(WorkOS, Medium/independent security writers) describing memory poisoning
|
|
38
|
+
as an attack that "waits" — content stored in one session that steers a
|
|
39
|
+
consequential action weeks later, in a different session.
|
|
40
|
+
|
|
41
|
+
Existing detection work in this space (e.g. "Ratine", MEMSAD, and similar)
|
|
42
|
+
operates at **runtime**, inspecting the actual content sitting in a live
|
|
43
|
+
memory store / vector database for injected instructions or anomalies.
|
|
44
|
+
Nothing found in that search is a **static, source-code-level AST linter**
|
|
45
|
+
that flags the *write call site itself* — i.e. catches the vulnerable
|
|
46
|
+
pattern in code review, before the agent ever runs, the same way a SAST tool
|
|
47
|
+
catches a SQL-injection-shaped string concatenation before the query ever
|
|
48
|
+
executes. That's the gap `agentmemorytaintgap` fills.
|
|
49
|
+
|
|
50
|
+
## Honest relationship to echofence (verified, not assumed)
|
|
51
|
+
|
|
52
|
+
Both tools were built in this workspace and both are prompt-injection
|
|
53
|
+
adjacent, so the distinction needs to be genuine, not cosmetic. This was
|
|
54
|
+
checked against echofence's actual README/DETAILS, not assumed:
|
|
55
|
+
|
|
56
|
+
- **echofence's scope** (confirmed from its own docs): it flags untrusted
|
|
57
|
+
*external* content (web scrape, retrieved RAG doc, email/file/PDF text,
|
|
58
|
+
tool/MCP output) built directly into an **LLM prompt/message sink**
|
|
59
|
+
(`messages=`, `prompt=`, LangChain message constructors, etc.) with no
|
|
60
|
+
delimiter/isolation. Its own DETAILS.md explicitly scopes it as the
|
|
61
|
+
*indirect* OWASP LLM01 variant — external content reaching a prompt.
|
|
62
|
+
Nothing in echofence's source recognizes memory-store write methods
|
|
63
|
+
(`save_context`, `chat_memory.add_*`, vector-store `.add`/`.upsert`, or
|
|
64
|
+
custom `remember`/`store_memory` helpers) as sinks at all — its sink list
|
|
65
|
+
is specifically LLM call kwargs and chat-message constructors.
|
|
66
|
+
- **agentmemorytaintgap's scope**: the sink is a **memory-write call**, not
|
|
67
|
+
a prompt-construction call. The artifact produced by a miss is different
|
|
68
|
+
too — an unflagged echofence gap produces one bad response; an unflagged
|
|
69
|
+
agentmemorytaintgap gap produces a **persisted, replayable** poisoned
|
|
70
|
+
memory entry that resurfaces in every subsequent turn that reads that
|
|
71
|
+
memory back.
|
|
72
|
+
- **They do not overlap in code**: echofence never inspects `.save_context`/
|
|
73
|
+
`.chat_memory.*`/vector-store `.add`/`.upsert`/`remember`-shaped calls as
|
|
74
|
+
sinks, and agentmemorytaintgap never inspects `messages=`/`prompt=`
|
|
75
|
+
LLM-call kwargs as sinks. A codebase that calls a tool, feeds the *raw*
|
|
76
|
+
result straight into a prompt (echofence's concern) and *separately*
|
|
77
|
+
saves that same raw result into long-term memory (agentmemorytaintgap's
|
|
78
|
+
concern) needs both tools — fixing one does not fix the other, since the
|
|
79
|
+
fix locations (the prompt-construction call vs. the memory-write call)
|
|
80
|
+
are different lines of code, and a codebase can have either problem
|
|
81
|
+
without the other (e.g. a raw tool result is fenced before hitting the
|
|
82
|
+
prompt, echofence clean, but the *same* raw value is separately persisted
|
|
83
|
+
into memory unsanitized, agentmemorytaintgap flags it — or vice versa: a
|
|
84
|
+
tool result is sanitized before being remembered, but a *different*,
|
|
85
|
+
unrelated external string is dropped unfenced into a prompt elsewhere).
|
|
86
|
+
- **Timing/risk-shape distinction (the actual justification for a separate
|
|
87
|
+
tool, not just a separate sink list)**: echofence's risk window is one
|
|
88
|
+
request/response cycle — the poisoned content is gone once that call
|
|
89
|
+
returns. agentmemorytaintgap's risk window is the **lifetime of the
|
|
90
|
+
memory store** — a write that succeeds once can be read back and
|
|
91
|
+
re-injected into arbitrarily many future prompts, across sessions, without
|
|
92
|
+
the attacker doing anything further. This mirrors exactly the distinction
|
|
93
|
+
the 2026 memory-poisoning literature draws between single-session prompt
|
|
94
|
+
injection and memory poisoning.
|
|
95
|
+
|
|
96
|
+
## Detection method (exact scope, stated honestly)
|
|
97
|
+
|
|
98
|
+
This is **not** full data-flow / taint tracking. It is a pragmatic,
|
|
99
|
+
heuristic, `ast`-only pass, described precisely so the false-positive and
|
|
100
|
+
false-negative surface is knowable:
|
|
101
|
+
|
|
102
|
+
1. **Per-function scope only.** Every `FunctionDef`/`AsyncFunctionDef` in the
|
|
103
|
+
module is analyzed independently. A memory write in one function can
|
|
104
|
+
never be traced to an assignment made in a different function, a class
|
|
105
|
+
`__init__`, or a module-level global — that is out of scope for v0.1.
|
|
106
|
+
2. **Memory-write recognition is call-shape + naming based**, listed in full
|
|
107
|
+
in README.md: `save_context`, `chat_memory.add_message`/
|
|
108
|
+
`add_user_message`/`add_ai_message` (LangChain-shaped); `.add`/`.upsert`
|
|
109
|
+
on a variable whose name contains `memory`, `mem_store`, or `long_term`
|
|
110
|
+
(generic vector-store-as-memory — there is no single standard "agent
|
|
111
|
+
memory" API across frameworks the way there is for e.g. HTTP requests,
|
|
112
|
+
so this is a naming heuristic, not a type check); and free-function or
|
|
113
|
+
bound-method calls named `remember`, `store_memory`, or `save_memory`
|
|
114
|
+
(custom memory pattern).
|
|
115
|
+
3. **Origin tracing is single-hop, within the writing function.** For the
|
|
116
|
+
value passed to a recognized memory-write call, the analyzer looks at:
|
|
117
|
+
- If it's a direct call expression (e.g. `search_tool.run(query)` used
|
|
118
|
+
inline) — classified immediately by call shape.
|
|
119
|
+
- If it's a bare variable name — the analyzer looks at how that name was
|
|
120
|
+
**last assigned** earlier in the same function (one hop back to that
|
|
121
|
+
assignment's right-hand side, not further). If that assignment's RHS
|
|
122
|
+
is itself another variable reference (not a call), the origin is
|
|
123
|
+
`unknown` rather than chased further — this is the "single-hop" line.
|
|
124
|
+
- If it's a bare function **parameter** with no prior assignment, the
|
|
125
|
+
analyzer checks only its **name** for a user-input signal
|
|
126
|
+
(`user_input`, `user_message`, `raw_input`, `user_query`, `message`) —
|
|
127
|
+
otherwise it's `unknown` (AT002), since a parameter's true origin is a
|
|
128
|
+
caller-scope question this tool does not follow.
|
|
129
|
+
- f-strings are inspected part-by-part; if any embedded expression is
|
|
130
|
+
untrusted, the whole f-string is untrusted; if all embedded parts are
|
|
131
|
+
provably trusted, it's trusted; otherwise unknown.
|
|
132
|
+
4. **Untrusted-origin call shapes**: `.run(...)`/`.invoke(...)` on a
|
|
133
|
+
variable named with `tool` in it; a call to a module-level function
|
|
134
|
+
decorated with a recognizable `@tool` decorator (matched by decorator
|
|
135
|
+
name only, not by import source); `requests`/`httpx`/`aiohttp`
|
|
136
|
+
`.get/.post/.put/.patch/.delete(...)`; a call to a function literally
|
|
137
|
+
named `fetch`.
|
|
138
|
+
5. **Trusted-origin call shapes** (short-circuit to safe, never flagged):
|
|
139
|
+
`.invoke(...)`/`.generate(...)`/`.predict(...)`/`.run(...)`/`__call__`
|
|
140
|
+
on a variable named with `llm`, `model`, or `chat` in it — the model's
|
|
141
|
+
own output.
|
|
142
|
+
6. **Sanitization short-circuit**: if any call in the same function whose
|
|
143
|
+
name contains `sanitize`, `clean`, `validate`, `tag_source`, or
|
|
144
|
+
`mark_untrusted` is applied (as a positional first argument) to a
|
|
145
|
+
variable that also appears in the memory-write argument expression, the
|
|
146
|
+
finding for that write is suppressed entirely — no confidence scoring,
|
|
147
|
+
binary short-circuit, matching the sibling tools' style.
|
|
148
|
+
7. Anything that can't be classified as trusted or untrusted by the rules
|
|
149
|
+
above is `unknown`, which becomes **AT002** (warning) rather than a
|
|
150
|
+
confident **AT001** (blocker) — the tool would rather under-claim
|
|
151
|
+
confidence than mislabel an ambiguous case as a hard blocker.
|
|
152
|
+
|
|
153
|
+
### Known false negatives (by design, v0.1)
|
|
154
|
+
|
|
155
|
+
- A memory-store variable not containing `memory`/`mem_store`/`long_term`
|
|
156
|
+
in its name (e.g. `db.add(tool_result)`) is invisible — the generic
|
|
157
|
+
vector-store pattern only fires on the naming heuristic.
|
|
158
|
+
- A tool call through a wrapper/adapter that doesn't match `.run()`/
|
|
159
|
+
`.invoke()` on a `tool`-named variable, or isn't `@tool`-decorated by a
|
|
160
|
+
name this tool recognizes, is invisible.
|
|
161
|
+
- Any origin resolved through more than one assignment hop, through a
|
|
162
|
+
class attribute, through a different function, or through a container
|
|
163
|
+
(list/dict) mutation is `unknown` at best, not a confident AT001.
|
|
164
|
+
- A sanitize/tag function that doesn't match the recognized name
|
|
165
|
+
substrings is invisible — the write would be flagged as if unsanitized.
|
|
166
|
+
|
|
167
|
+
### Known false positives (by design, v0.1)
|
|
168
|
+
|
|
169
|
+
- A variable genuinely named `memory`/`db_memory`/etc. that isn't actually a
|
|
170
|
+
long-term memory store will still trigger the generic vector-store
|
|
171
|
+
pattern if `.add()`/`.upsert()` is called on it.
|
|
172
|
+
- A function named `remember`/`store_memory`/etc. that does something
|
|
173
|
+
unrelated to agent memory (e.g. a cache) will still be treated as a
|
|
174
|
+
memory-write sink.
|
|
175
|
+
- A `sanitize`-named function that doesn't actually neutralize untrusted
|
|
176
|
+
content (e.g. it only trims whitespace) still short-circuits the finding
|
|
177
|
+
— the tool trusts the function's name, not its behavior.
|
|
178
|
+
|
|
179
|
+
## Verification performed for this release
|
|
180
|
+
|
|
181
|
+
- `pip install -e '.[dev]'` in an isolated editable install of this
|
|
182
|
+
package only.
|
|
183
|
+
- `pytest tests/ -v` — all unit and CLI tests passing (see `docs/USAGE.md`
|
|
184
|
+
test-run output).
|
|
185
|
+
- Real CLI runs against all four `examples/` fixtures: an AT001 blocker
|
|
186
|
+
(tool output and HTTP fetch stored unsanitized), a clean sanitized
|
|
187
|
+
example, a clean example storing the LLM's own response, and an AT002
|
|
188
|
+
ambiguous-origin example — confirmed each produces the expected exit code
|
|
189
|
+
and finding shape.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT © Jay Tank
|
|
@@ -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.
|
|
@@ -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,138 @@
|
|
|
1
|
+
# agentmemorytaintgap
|
|
2
|
+
|
|
3
|
+
**Catch untrusted content being written into an AI agent's long-term memory as if it were a trusted fact.**
|
|
4
|
+
|
|
5
|
+
Prompt injection usually gets pictured as a single bad turn: a poisoned web
|
|
6
|
+
page or tool result sneaks into one prompt, the model says something wrong
|
|
7
|
+
once, and the blast radius ends there. Agent **memory** breaks that
|
|
8
|
+
assumption. A growing class of agent frameworks let the agent persist
|
|
9
|
+
content — a tool result, a summary, a "fact" — into a long-term memory store
|
|
10
|
+
that gets read back and fed into *every future prompt* as if it were the
|
|
11
|
+
agent's own trusted conclusion. If the content that gets written was actually
|
|
12
|
+
attacker-controlled (a malicious tool response, a crafted user message, a
|
|
13
|
+
poisoned web page), and nothing strips or tags it first, the poison survives
|
|
14
|
+
for the lifetime of that memory store — this is the "agent memory poisoning"
|
|
15
|
+
risk that's an increasingly-discussed, distinct branch of prompt injection in
|
|
16
|
+
2026.
|
|
17
|
+
|
|
18
|
+
`agentmemorytaintgap` reads your source with Python's `ast` module — no
|
|
19
|
+
imports, no execution — and flags the memory-**write** call sites where that
|
|
20
|
+
can happen:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
$ agentmemorytaintgap agent/
|
|
24
|
+
|
|
25
|
+
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).
|
|
26
|
+
agent_memory.save_context({"input": user_query}, {"output": tool_result})
|
|
27
|
+
|
|
28
|
+
1 file(s) scanned · 1 blocker(s) · 0 warning(s)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Exit code `1` on a blocker, so it drops straight into pre-commit or CI.
|
|
32
|
+
|
|
33
|
+
## What it flags
|
|
34
|
+
|
|
35
|
+
| Rule | Severity | Fires when… |
|
|
36
|
+
| :--- | :--- | :--- |
|
|
37
|
+
| **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. |
|
|
38
|
+
| **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. |
|
|
39
|
+
|
|
40
|
+
The LLM's own generated response (e.g. `llm.invoke(...)`) is treated as
|
|
41
|
+
trusted and is never flagged — the concern here is specifically *external*
|
|
42
|
+
content being stored as if it were the agent's own conclusion.
|
|
43
|
+
|
|
44
|
+
### Recognized memory-write shapes
|
|
45
|
+
|
|
46
|
+
- LangChain-shaped: `.save_context(...)`, `.chat_memory.add_message(...)` /
|
|
47
|
+
`.add_user_message(...)` / `.add_ai_message(...)`.
|
|
48
|
+
- Generic vector-store-as-memory: `.add(...)` / `.upsert(...)` called on a
|
|
49
|
+
variable whose name contains `memory`, `mem_store`, or `long_term` — there
|
|
50
|
+
is no single standard "agent memory" API the way there is for HTTP, so
|
|
51
|
+
this is a **naming heuristic**, documented honestly in `DETAILS.md`.
|
|
52
|
+
- Custom memory helpers: a call to `remember(...)`, `store_memory(...)`, or
|
|
53
|
+
`save_memory(...)`, as a free function or bound method.
|
|
54
|
+
|
|
55
|
+
### Recognized untrusted origins
|
|
56
|
+
|
|
57
|
+
- A tool call: `.run(...)` / `.invoke(...)` on a variable named like `tool`,
|
|
58
|
+
or a call to a function decorated with a recognizable `@tool` decorator.
|
|
59
|
+
- An external fetch: `requests`/`httpx`/`aiohttp` `.get/.post/...(...)`, or a
|
|
60
|
+
call to a function literally named `fetch`.
|
|
61
|
+
- A raw user-input function parameter (named like `user_input`,
|
|
62
|
+
`user_message`, `raw_input`, `user_query`, or `message`).
|
|
63
|
+
|
|
64
|
+
### Safe-marker short-circuit
|
|
65
|
+
|
|
66
|
+
If a call whose name contains `sanitize`, `clean`, `validate`, `tag_source`,
|
|
67
|
+
or `mark_untrusted` is applied to the value anywhere in the
|
|
68
|
+
same function before the memory write, the finding does not fire — the
|
|
69
|
+
short-circuit is deliberately generous, the same style as its sibling tools.
|
|
70
|
+
|
|
71
|
+
## How it relates to echofence
|
|
72
|
+
|
|
73
|
+
`echofence` and `agentmemorytaintgap` are both prompt-injection-adjacent AST
|
|
74
|
+
linters, and they are **deliberately distinct, non-overlapping tools**:
|
|
75
|
+
|
|
76
|
+
- **[`echofence`](https://github.com/jay-tank/echofence) — input side, single
|
|
77
|
+
turn.** Flags untrusted *external* content reaching a live LLM **prompt**
|
|
78
|
+
directly — the indirect variant of OWASP LLM01. The risk window is one
|
|
79
|
+
request/response cycle.
|
|
80
|
+
- **`agentmemorytaintgap` — persistence side, every future turn.** Flags
|
|
81
|
+
untrusted content being **written into long-term memory** that will be
|
|
82
|
+
read back and replayed as trusted context across *every subsequent turn*,
|
|
83
|
+
potentially for the lifetime of the memory store. The artifact, the
|
|
84
|
+
timing, and the risk shape are different: a poisoned prompt affects one
|
|
85
|
+
answer; a poisoned memory write affects all future answers until someone
|
|
86
|
+
notices and purges the store.
|
|
87
|
+
|
|
88
|
+
See `DETAILS.md` for the full, honest comparison — including why this is not
|
|
89
|
+
just "echofence but for a different sink."
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install agentmemorytaintgap
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Usage
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
agentmemorytaintgap agent/ # scan a directory
|
|
101
|
+
agentmemorytaintgap memory_handler.py # scan a file
|
|
102
|
+
agentmemorytaintgap agent/ --strict # AT002 warnings fail the run too
|
|
103
|
+
agentmemorytaintgap agent/ --json # machine-readable output
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### In CI
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
- run: pip install agentmemorytaintgap
|
|
110
|
+
- run: agentmemorytaintgap agent/ --strict
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Exit codes: `0` clean · `1` a blocker (AT001), or any finding under
|
|
114
|
+
`--strict` · `2` usage error.
|
|
115
|
+
|
|
116
|
+
## Honest limitations
|
|
117
|
+
|
|
118
|
+
`agentmemorytaintgap` is a **pragmatic, heuristic, single-hop,
|
|
119
|
+
same-function-scope** analyzer — **not** full data-flow / taint analysis.
|
|
120
|
+
See `DETAILS.md` for the complete breakdown, but concretely:
|
|
121
|
+
|
|
122
|
+
- It only recognizes the memory-write shapes and naming conventions listed
|
|
123
|
+
above. A memory variable that doesn't contain `memory`/`mem_store`/
|
|
124
|
+
`long_term` in its name, or a tool call that doesn't match the recognized
|
|
125
|
+
`@tool`/`.run()`/`.invoke()` shapes, is invisible to v0.1.
|
|
126
|
+
- Tracing is single-hop and scoped to one function. A value laundered
|
|
127
|
+
through a helper function it doesn't look inside of, or passed across
|
|
128
|
+
functions before being written to memory, will not be traced.
|
|
129
|
+
- A sanitize/tag call anywhere in the function short-circuits the finding —
|
|
130
|
+
it trusts that the call actually does what its name implies; it does not
|
|
131
|
+
verify that.
|
|
132
|
+
|
|
133
|
+
Treat it as a fast reviewer that catches the obvious, high-value cases on
|
|
134
|
+
every PR, paired with human judgment for the rest.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT © Jay Tank
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Usage
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install agentmemorytaintgap
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Or, for local development, from the package directory:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e ".[dev]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Scanning
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
agentmemorytaintgap path/to/agent_code.py
|
|
19
|
+
agentmemorytaintgap path/to/agent_dir/
|
|
20
|
+
agentmemorytaintgap file_a.py file_b.py dir_c/
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Multiple files and directories can be passed together; directories are
|
|
24
|
+
scanned recursively for `*.py` files.
|
|
25
|
+
|
|
26
|
+
## Flags
|
|
27
|
+
|
|
28
|
+
| Flag | Effect |
|
|
29
|
+
| :--- | :--- |
|
|
30
|
+
| `--json` | Emit a JSON payload (`findings`, `blockers`, `warnings`, `files_scanned`) instead of text. |
|
|
31
|
+
| `--strict` | Also exit `1` when only AT002 warnings are present (default: AT002 alone exits `0`). |
|
|
32
|
+
|
|
33
|
+
## Exit codes
|
|
34
|
+
|
|
35
|
+
| Code | Meaning |
|
|
36
|
+
| :--- | :--- |
|
|
37
|
+
| `0` | No AT001 blockers (and, without `--strict`, no AT002-only failure). |
|
|
38
|
+
| `1` | At least one AT001 blocker, or (with `--strict`) at least one AT002 warning. |
|
|
39
|
+
| `2` | Usage error: no Python files found, or a file failed to parse (syntax error). |
|
|
40
|
+
|
|
41
|
+
## Example: human-readable output
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
$ agentmemorytaintgap examples/unsafe_tool_output.py
|
|
45
|
+
|
|
46
|
+
BLOCKER AT001 examples/unsafe_tool_output.py:6: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).
|
|
47
|
+
agent_memory.save_context({"input": user_query}, {"output": tool_result})
|
|
48
|
+
BLOCKER AT001 examples/unsafe_tool_output.py:12: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).
|
|
49
|
+
memory_store.add(response.text)
|
|
50
|
+
|
|
51
|
+
1 file(s) scanned · 2 blocker(s) · 0 warning(s)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Example: JSON output
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
agentmemorytaintgap examples/unsafe_tool_output.py --json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"findings": [
|
|
63
|
+
{
|
|
64
|
+
"rule": "AT001",
|
|
65
|
+
"severity": "blocker",
|
|
66
|
+
"path": "examples/unsafe_tool_output.py",
|
|
67
|
+
"line": 6,
|
|
68
|
+
"col": 4,
|
|
69
|
+
"message": "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).",
|
|
70
|
+
"source_line": " agent_memory.save_context({\"input\": user_query}, {\"output\": tool_result})"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"blockers": 2,
|
|
74
|
+
"warnings": 0,
|
|
75
|
+
"files_scanned": 1
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## In CI
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
- name: Install
|
|
83
|
+
run: pip install agentmemorytaintgap
|
|
84
|
+
- name: Check agent memory writes
|
|
85
|
+
run: agentmemorytaintgap agent/ --strict
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Running the test suite locally
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install -e ".[dev]"
|
|
92
|
+
pytest tests/ -v
|
|
93
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
- `unsafe_tool_output.py` — two AT001 blockers: raw tool/search output and a
|
|
4
|
+
raw HTTP fetch response stored directly into memory with no sanitization.
|
|
5
|
+
- `clean_sanitized.py` — the same tool-output-into-memory pattern, but the
|
|
6
|
+
value passes through a `sanitize(...)` call first. No finding.
|
|
7
|
+
- `clean_llm_response.py` — the LLM's own generated response (`llm.invoke(...)`)
|
|
8
|
+
stored into memory. Never flagged — this is the expected, safe pattern.
|
|
9
|
+
- `ambiguous_origin.py` — an AT002 warning: the stored value is a bare
|
|
10
|
+
function parameter with no naming signal either way.
|
|
11
|
+
|
|
12
|
+
Run against all of them:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
agentmemorytaintgap examples/
|
|
16
|
+
```
|