English | 中文
A code audit tool for AI agent projects. It reads LLM / Agent code, finds dangerous call sites, and then works out whether each one's argument can actually be controlled from outside. When it cannot tell, it writes "unknown" rather than giving a guess.
Its output is a list of candidates, not a list of confirmed vulnerabilities. Every one of them still needs a person to look at it.
| Try it | agentaudit scan --demo, with no CodeQL, no API key and nothing to download |
| What it misses | LIMITATIONS.md, which lists everything it cannot find |
Agent projects are full of dangerous calls: eval, subprocess, open, requests and so on.
Most of them are necessary. To run a command you have to call subprocess. So classic taint
analysis flags all of them, and what comes back is a long list of "there is a dangerous call
here", which is not much use.
# Candidate. Dangerous call? yes. Is it a vulnerability? probably not.
handler = eval(spec_name) # spec_name comes from a built-in table in this fileThe real question is not whether a dangerous call exists. It is these three:
| # | Question | Answered by |
|---|---|---|
| ① | Is there a dangerous call? | static analysis |
| ② | Is its argument externally controlled? | this project |
| ③ | Does that argument survive to the sink? | this project |
agentaudit does the second and the third. It turns both into checks that run. And when it
cannot tell, it outputs needs_human, meaning a person has to decide. That is a real result,
not a failure.
Requires Python 3.10 or newer. The CodeQL CLI is optional, and it is a few hundred MB. Without it the scanner runs in the offline mode, which finds less but needs nothing beyond PyYAML.
git clone <this repo> && cd agentaudit
./scripts/setup.sh # the dependency + the CodeQL CLI, or:
pip install -e . # the dependency only; gives you the `agentaudit` commandEither install lets you run the scripts directly (python app/scan.py ...). The editable
install also gives you a single command:
agentaudit scan --demo
agentaudit explain out/round-demo --index 3
agentaudit serve out/round-demo
agentaudit --helpOutput goes to ./out under the current directory, or to $AGENTAUDIT_HOME if you set it.
Nothing is written into the installation.
agentaudit selftest # or: python tools/run_selftests.py
agentaudit scan --demo # 13 candidates from the bundled sample projectBoth run in seconds and need nothing else installed. If these two pass, the install works.
If you would rather not install CodeQL, the image has it built in:
docker build -t agentaudit .
docker run --rm agentaudit # bundled sample
docker run --rm -v "$PWD/myproject:/scan/myproj:ro" \
agentaudit --path /scan/myproj --tag myproj --out /scan/outThe build picks the CodeQL binary for the architecture it is building for, so x64 and arm64 both work, Apple Silicon included.
The image entry point is agentaudit scan, so the arguments are the same ones you would type
locally. Other subcommands need their own entry point:
docker run --rm --entrypoint agentaudit agentaudit log
docker run --rm --entrypoint /opt/agentaudit/_tools/codeql/codeql agentaudit versionThis Dockerfile was written on a machine without Docker, so the first build is the first test of it. It is written to fail early: the CodeQL CLI is executed once during the build, so if the layout is wrong the build stops, instead of producing an image whose scanner cannot run. CI builds it and runs a smoke test on every push.
| Command | What it does |
|---|---|
agentaudit interactive |
a menu, for when you don't want to remember flags (a bare agentaudit opens it too) |
agentaudit scan |
scan a target: --demo, --path DIR, --repo OWNER/NAME |
agentaudit explain <round> |
show why a candidate was reported |
agentaudit serve <round> |
read a round in a browser (localhost only) |
agentaudit report <round> |
render the report draft |
agentaudit index <round> |
render the HTML index |
agentaudit sarif <round> |
export the round as SARIF 2.1.0 |
agentaudit round --tag NAME |
run one round over a target table |
agentaudit all --tag NAME |
prepare → scan → report, resumable |
agentaudit selftest |
run the rule self-tests |
agentaudit validate |
check core/config/*.yml |
agentaudit log |
show or export the local scan statistics |
agentaudit targets |
find and screen GitHub targets, for a batch rather than one repo |
One command scans something, with no target list and no database to build by hand:
agentaudit scan --demo # the bundled sample project
agentaudit scan --path ./myproject # a project already on disk
agentaudit scan --repo owner/name # fetch a GitHub repo and scan itTo scan many repositories instead of one, there is a different route: find and screen candidates, fetch sources and build databases, then scan the batch. See Scanning a batch under "How it works".
A bare agentaudit opens this in your terminal:
1 Scan a target
2 Open a previous round
3 Run the rule self-tests
4 Validate the rule configuration
5 Scan statistics (local, statistics only)
6 List the commands
0 Quit
It prints the command line that each choice runs, so the menu teaches you the flags rather than hiding them. And where there is no terminal, in a pipe or in CI for example, it refuses to start rather than blocking on input.
| File | What it is |
|---|---|
findings.tsv |
the candidate table, one row per candidate, and the main artefact |
report_draft.md |
the write-up, one section per candidate |
index.html |
sortable / searchable table of every candidate |
agentaudit.sarif |
the candidates as SARIF 2.1.0, for a code-scanning UI or an editor |
results.sarif |
the CodeQL frontend's own raw output (full mode only) |
meta.json |
which mode ran, which rule switches were on, how long it took |
Every one of those rows is a candidate. Nothing in this pipeline executes the code it looks at; it only reads it. So none of it is confirmed.
findings.tsv is the raw table. These three commands are how you read it.
agentaudit explain out/round-x --index 3It prints the source around the flagged line, the call chain that reaches it, the guards on that path, and where the dangerous argument comes from. If the scan could not determine that, it says so. Run this before believing or dismissing a candidate.
agentaudit serve out/round-xBinds to 127.0.0.1, serves exactly that one directory, and refuses directory listings and path
traversal.
report_draft.md is generated for you, and its header states plainly that the verdicts inside
are AI-assisted and were not verified by execution. examples/ has a complete
synthetic sample, and its rows cover the four outcomes the scanner distinguishes: vuln,
not_vuln (not externally controllable), not_vuln (blocked by a validator) and needs_human
(origin undeterminable). The last one is the important one. A scanner that only ever answers yes
or no is pretending to be more certain than it is.
Every run writes agentaudit.sarif. It uses relative paths only, with no absolute path and no
originalUriBaseIds, so a copy taken off your machine does not reveal your directory layout.
Every result is level: warning and the triage verdict travels in properties, because SARIF
levels are read by tools that never see the caveats. Rows already triaged not_vuln are left
out; add --all to agentaudit sarif to include them.
If you are willing to send me your scan statistics, I would be genuinely grateful. They cost you nothing, and they contain no code. They are also the one thing I cannot produce on my own: how this scanner behaves on code I have never seen. A check that fires on your code and never on mine is the most useful thing you can send me.
Every scan appends one line to ./data/scan_log.jsonl, locally:
| Recorded | Never recorded |
|---|---|
| how many files, how many candidates | source code, or any fragment of it |
| the split across checks and across verdicts | file paths or file names |
| elapsed time, tool version, hashes of the rule config | the project name, or anything identifying it |
| your Python minor version, and OS family | anything about you |
agentaudit log # summarise what you have scanned
agentaudit log --export # a small aggregate, written to scan_stats.json
agentaudit log --clear # delete itNothing is ever uploaded. There is no network code in any of this, so the only way any of
it leaves your machine is if you carry it out yourself. --export re-checks the aggregate
against the table above and refuses to write anything that fails, so read it before you decide.
If you are happy with it, open an
issue and paste it in. If you would rather
not, that is a perfectly good answer too. The log is yours, and
--no-log (or AGENTAUDIT_NO_LOG=1) turns the whole thing off.
Worth knowing before you trust any number it gives you.
| Mode | Needs | Finds |
|---|---|---|
| full | the CodeQL CLI | the CodeQL queries and the AST checks; writes the raw SARIF |
| offline | nothing at all | the AST checks only |
If the CodeQL CLI is missing the run says so and uses the offline mode. The mode used is
recorded in meta.json and printed at the top of the report, because the two do not find the
same things. --no-codeql forces the offline mode.
It spends API credits, so it only runs when you ask:
agentaudit scan --path ./myproject --llmThe run prints the candidate count and the hard cap (AF_LLM_MAX_CNY, default 30 CNY) before it
starts, and reports what it actually spent afterwards. Without --llm you get the candidate
list with no verdicts, and the report says so rather than implying anything was judged.
A vuln verdict whose argument source is unknown is downgraded to needs_human. Those are the
unstable ones: run the same candidate repeatedly and they flip noticeably more often than the
rest. The original verdict stays in ai_reason and in verdicts.json, so nothing is lost;
--no-unknown-downgrade turns the behaviour off, and the round records whether it was on.
agentaudit scan --path ./myproject --fail-on any # exit 1 if anything is reported
agentaudit scan --path ./myproject --llm --fail-on vuln # exit 1 only on a `vuln` verdict
agentaudit scan --path ./myproject --diff origin/main # only files changed since a ref--diff needs the source tree to be a git checkout, and is meant for "did this pull request
introduce anything", not for a full audit.
agentaudit scan --path ./big-repo --ignore 'vendor/**' --ignore '*/generated/*.py'Patterns are globs relative to the source root, and they are recorded in the round so that two
rounds stay comparable. What you exclude is what you did not scan, so "no candidates in
vendor/" means nothing if vendor/ was skipped.
| Option | What it does |
|---|---|
--demo |
scan the bundled sample project |
--path DIR |
scan a directory on disk |
--repo OWNER/NAME |
fetch a GitHub repo and scan it |
--project NAME |
name used in reports (default: derived from the path) |
--tag NAME |
round directory name (default: derived) |
--out DIR |
where to write the round (default: ./out) |
--llm |
triage with an LLM; it spends credits, so it is off by default |
--no-unknown-downgrade |
keep a vuln verdict even when the argument source is unknown |
--no-codeql |
force the offline mode |
--lang en|zh |
report language (default en) |
--diff [REF] |
only report files changed since REF |
--fail-on none|any|vuln |
exit non-zero, as a build gate |
--ignore GLOB |
skip paths matching this glob (repeatable) |
--serve |
serve the round on localhost when the scan finishes |
--no-sarif |
do not write agentaudit.sarif |
--no-log |
do not append to the local scan log |
The dangerous-call list lives in core/config/sinks.yml, a single YAML file holding
52 dangerous-call rules rather than hard-coded logic. core/gen_ql.py reads it and generates
the CodeQL queries, so adding your own dangerous function is a config change, not a code
change.
| Group | Examples | Default |
|---|---|---|
| Baseline | subprocess.run / Popen, os.system, requests.get/post/request, eval, exec, jinja2.from_string, *.execute(...) |
on |
| Database clients | .query(...) on receivers whose name ends in conn |
on |
File I/O (AF_SINKS_FS) |
os.remove/unlink/rename/mkdir/chmod/..., shutil.move/rmtree/... |
off |
Path construction (AF_SINKS_PATH) |
os.path.join, os.path.abspath |
off |
HTTP clients (AF_SINKS_HTTP) |
httpx.get/post/..., httpx.Request, httpx.Client |
off |
Dynamic dispatch (AF_SINKS_DYNIMP) |
importlib.import_module, __import__, import_module |
off |
The groups that are off are off because they are noisy. Turning one on trades precision for
candidate volume. The switch values are recorded in each round's meta.json.
Beyond these calls, the kernel checks three more things: whether the endpoints in one file agree
about authentication, whether one function contains both the safe and the unsafe way of doing
the same job, and whether a dangerous point can be reached from an entry point at all.
(Code calls a dangerous point a sink, which is where switch names like AF_SINKS_FS come
from.)
Adding your own dangerous function is a few lines of YAML. CONFIGURING.md has
the field reference, and agentaudit validate checks your edit.
These are deliberate trade-offs, not bugs. The long version is in LIMITATIONS.md: what the analysis cannot see at all, and which of its silences are meaningful.
- Python only. The static frontend is bound to CodeQL's Python pack.
- The triage layer is noisy. Re-run a candidate and a noticeable share of the verdicts
change. Suspiciously stable results are usually a caching artefact, so change
--tagwhen re-judging. arg_source=unknownis not trustworthy. That verdict means the model itself does not know where the argument comes from; those rows are downgraded by default.- "No caller found" is not "unreachable." Only name-based matching is done, so cross-file and dynamic dispatch are missed.
- Framework mediation. When the implementation lives in
site-packagesrather than in the scanned tree, static analysis stops at the import. - The offline mode finds less. No inter-procedural reachability, no framework modelling.
- Regenerating the CodeQL queries needs the AgentFuzz reference pack. Only for regeneration;
the committed
core/ql/*.qlwork without it (AF_PAPER_QLto point at it). - Every finding needs human confirmation. The tool produces candidates, not conclusions.
Secrets are always read from files or environment variables, never committed.
| Env var | Purpose | Default |
|---|---|---|
DEEPSEEK_KEY_FILE |
path to the LLM API key file | ~/.deepseek_key |
GITHUB_KEY_FILE |
path to a GitHub token file (raises API limits) | ~/.github_token |
AF_LLM_MAX_CNY |
hard per-run spend cap; the run stops and reports when reached | 30 |
AF_JUDGE_MT |
max output tokens for the triage layer | 8000 |
AF_CODEQL |
path to the CodeQL CLI | ./_tools/codeql/codeql[.exe] |
AF_PAPER_QL |
path to the AgentFuzz reference QL pack | ./agentfuzz-official/ql |
AF_SRC_DIR / AF_DB_DIR |
where fetched sources / databases live | ./_targets/src, ./_targets/db |
AF_TARGET_MAP |
JSON map {"short-name": "source root"} |
read from core/config/targets*.yml |
AF_SINKS_FS / _PATH / _HTTP / _DYNIMP |
enable extra sink groups | off |
AF_SINKS_EXTRA |
include the source: extra entries at all |
1 |
AF_PATH_FILTER |
path filter mode (rel = project-relative) |
rel |
AF_SOURCES_FILE |
an additional untrusted-source list, added on top of the default | core/config/sources.yml |
AF_NO_DATAFLOW |
disable inter-procedural argument-source analysis | 0 |
AGENTAUDIT_HOME |
where output (out/, data/) goes |
the current directory |
AGENTAUDIT_NO_LOG |
set to 1 to stop recording scan statistics |
unset |
AGENTAUDIT_ASCII |
set to 1 to draw the banner with ASCII only |
auto-detected |
The complete list, including the triage-layer patches and the switches for A/B runs, is in CONFIGURING.md. A test fails if one is added without being documented.
This section is for two kinds of reader: someone reading the code, and someone scanning a batch rather than a single repository.
One target goes through four stages, and agentaudit scan drives all of them:
| Stage | Where | What it does |
|---|---|---|
| Static frontend | core/static_frontend.py |
build a CodeQL database, run the queries in core/ql/, parse the SARIF |
| Strong/weak duality | core/duality.py |
one function doing the same job safely on one line and unsafely on another |
| Triage | core/llm_judge.py |
the five axes: verdict / by_design / scope_exceeds / arg_source / guards_real |
| Report material | app/make_report.py, app/make_index.py, tools/to_sarif.py |
the draft, the sortable table, the SARIF |
Two more run across all of those: core/consistency.py decides what the evidence does not
support and refuses to report it, and core/cfg.py owns the rule configuration.
core/gate.py (is this target worth scanning at all) and core/upstream.py (has this already
been reported upstream) are used by the batch path only.
A batch has two slow stages, and both can be restarted. You can stop and re-run either of them, and finished work is skipped.
# 1) build a candidate list (needs a GitHub token, and a few minutes)
agentaudit targets fetch # -> data/target_pool/pool.json
agentaudit targets screen # -> data/target_pool/screened.json
# 2) pick the ones you want into a batch file, then fetch sources and build databases
python app/prepare_targets.py data/target_pool/batch1.json
# 3) scan and write the reports
agentaudit round --tag my-round --targets-file core/config/targets.yml
agentaudit report out/round-my-round
agentaudit index out/round-my-roundagentaudit all --tag my-round --batch data/target_pool/batch1.json --targets-file core/config/targets.yml chains those steps, skipping whatever is already done.
The screener excludes repositories you have already looked at, which is what makes "not yet
examined" mean something. That list lives in core/config/known_targets.yml, and it does not
ship; copy the example and add your own. It is a file rather than a constant in the code,
because a list of real repositories says more about whoever wrote the tool than about the tool.
Mind the cost. The triage stage spends LLM credits. Scripts print an estimated cost up front, and
AF_LLM_MAX_CNYhard-stops a run when the cap is reached.
core/ the kernel (static frontend, triage, reachability, taint tracing, CodeQL queries)
core/config/ the rules: sinks.yml, sources.yml, filters.yml -- see CONFIGURING.md
tools/ reusable tooling (target selection, fetching, individual checks, self-tests)
app/ entry points (scan.py, run_all.py, explain.py, serve.py, cli.py)
demo/ a deliberately vulnerable sample project, for `--demo`
examples/ synthetic sample output
tests/ the test suite CI runs
scripts/ setup.sh
| CONFIGURING.md | the rule fields, and how to add your own dangerous function |
| LIMITATIONS.md | what the tool does not find |
| CONTRIBUTING.md | how to run and extend the tests |
| CHANGELOG.md | what changed |
I wrote this while working on AI agent security. What kept stopping me was not finding a dangerous call; it was deciding what to do with one. Run the same candidate past a model twice and you do not always get the same answer. A tool that hides that is worse than one that misses things, because you cannot tell which of its outputs to trust.
So the rule I built it around is narrow: nothing here is allowed to sound more certain than it
is. needs_human is a real verdict. Evidence that could not be determined is printed as "not
determined" instead of guessed. Every report says it was not verified by execution.
LIMITATIONS.md is kept current on purpose, because a tool that only lists
its strengths is not telling you anything.
If you run it and it is wrong, in either direction, that is the most useful thing you can send me. Thank you for reading this far.
The CodeQL frontend and the baseline sink list are derived from AgentFuzz:
Fengyu Liu, Yuan Zhang, Jiaqi Luo, Jiarun Dai, Tian Chen, Letian Yuan, Zhengmin Yu, Youkun Shi, Ke Li, Chengyuan Zhou, Hao Chen, Min Yang. Make Agent Defeat Agent: Automatic Detection of Taint-Style Vulnerabilities in LLM-based Agents. 34th USENIX Security Symposium (USENIX Security '25), 2025, pp. 3767–3786.
core/gen_ql.py regenerates the queries from that reference pack, which is why AF_PAPER_QL
points at it. Everything under source: extra in core/config/sinks.yml, the reachability
check, the parameter-taint check and the triage layer are additions on top of the baseline.
MIT. See LICENSE.
Please use it only against targets you are authorised to test.
