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.
@@ -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,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -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
+ ```
@@ -0,0 +1,5 @@
1
+ """AT002: memory write whose argument origin can't be confidently traced."""
2
+
3
+
4
+ def store_note(agent_memory, note):
5
+ agent_memory.save_context({"input": "note"}, {"output": note})
@@ -0,0 +1,7 @@
1
+ """Clean: the LLM's own generated response stored into memory - never flagged."""
2
+
3
+
4
+ def handle_turn(agent_memory, llm, user_query):
5
+ reply = llm.invoke(user_query)
6
+ agent_memory.save_context({"input": user_query}, {"output": reply})
7
+ return reply