The zero-trust input security layer for AI agents. Scan untrusted input — documents, source files and agent rule files — before it reaches an LLM: detect hidden prompt-injection, sanitize it, label its trustworthiness, and enforce policy. External content is data, never authority.
aiitg stands for AI Input Trust Gateway — and every letter is a design decision:
| Letter | Meaning | Why it matters |
|---|---|---|
| AI | The problem space is AI/LLM/Agent | This is security for AI, not generic document tooling |
| Input | We guard the input side | Most AI security focuses on outputs/alignment; the input (untrusted documents) is the attacker's real entry point |
| Trust | We make trust explicit and measurable | A document isn't trusted by default — it earns a safe / caution / dangerous label with evidence |
| Gateway | We sit between the document and the model | Like an email gateway filters mail before it reaches the inbox, aiitg filters content before it reaches the LLM's context |
The philosophy in one line: external content is data, never authority. A document should never be able to instruct an AI — it should only ever be read by an AI, after being checked.
That's also why the tool is deliberately named as a gateway, not a scanner: a scanner reports; a gateway enforces. aiitg doesn't just tell you a document is dangerous — it blocks it, sanitizes it, requires human approval, and audits every decision.
2025–2026 research and real-world incidents repeatedly demonstrated a class of attack: instructions hidden inside documents that humans cannot see, but LLMs read and obey.
- Peer-review manipulation: authors hide prompts in manuscripts to make AI reviewers give top scores (arXiv:2507.06185, 2508.20863, 2509.09912)
- Resume injection: hidden instructions in resumes force AI screeners to "must hire" (arXiv:2605.28999)
- Agent hijacking: hidden text in PDFs/web pages makes AI assistants leak secrets or execute dangerous actions
- Code-comment injection: an insecure objective written as an ordinary
# TODO:comment in a library the developer imports — the model complies because the marker is the same one real code uses (arXiv:2610.05139; see DET-008 below)
The document a human sees and the document an LLM reads can be completely different — that's the Human-AI Visibility Gap. This project turns that gap into auditable evidence.
Scan → trust label → sanitize → policy. A document with a hidden zero-width instruction is detected, labeled dangerous, sanitized, and blocked by policy before it can reach an LLM.
Untrusted input (docx/xlsx/xls/pdf/html/pptx)
│
▼
┌───────────────────────────────────────────────┐
│ FormatRegistry → parser → ParsedDocument │
│ (format-agnostic intermediate model) │
│ │
│ Detector chain (9 detectors) │
│ → Evidence list (coordinate-level) │
│ │
│ Sanitizer (strip / redact) │
│ → Safe text for LLM context │
│ │
│ Trust label (safe / caution / dangerous) │
│ Policy engine (allow/quarantine/approval/ │
│ block) │
│ → Decision + audit log + human approval │
└───────────────────────────────────────────────┘
│
▼
Safe LLM/Agent consumption
| Stage | Capability | Interface |
|---|---|---|
| M0 | Hidden content audit — 6 document formats × 7 detectors → coordinate-level JSON evidence | aiitg scan |
| M1 | Sanitization (strip/redact) + trust labeling (safe/caution/dangerous) | aiitg sanitize, aiitg trust |
| M2 | Policy enforcement (allow/quarantine/human_approval/block) + audit + human-approval queue + MCP server for agent frameworks | aiitg policy, aiitg audit, aiitg approvals, aiitg-mcp |
| M3 | Zero-touch adoption — a Claude Code PreToolUse hook that enforces policy before untrusted content enters the model context (fail-closed, cached, audited) |
aiitg hook pretooluse, aiitg hook-config |
| M4 | Code-context coverage — the same gate for source files and agent rule files: an unsafe instruction written as an ordinary comment (# TODO: / # INSTRUCTION: / # TASK:) is detected, blocks the read, and is defused in the sanitized substitute |
aiitg scan account.py |
| ID | Name | Detects | Severity |
|---|---|---|---|
| DET-001 | zero_width | Zero-width / invisible Unicode (U+200B, U+2060, U+FEFF…) | HIGH |
| DET-002 | hidden_style | White text, transparency, display:none, w:vanish |
HIGH |
| DET-003 | tiny_font | Extremely small font (≤2pt) | MEDIUM |
| DET-004 | hidden_sheet | Hidden sheets/rows/columns (with data) in xlsx/xls | MEDIUM |
| DET-005 | ooxml_nodes | OOXML hidden nodes (tracked-delete, altChunk, comments) | MEDIUM |
| DET-006 | annotations | Word comments, PDF annotations, HTML comments | LOW |
| DET-007 | document_meta | Metadata / VBA macros / JS signals | LOW |
| DET-008 | comment_directive | Insecure instruction hidden in an ordinary code comment or agent rule file — the directive frame (marker / addressed / imperative / authority / "skip the check") plus one of the ten injected-weakness classes | CRITICAL · HIGH · MEDIUM |
| DET-009 | speaker_notes | Directive/injection content in PowerPoint speaker notes — machine-readable but not shown when the deck is presented | HIGH · MEDIUM |
docx · xlsx · xls (legacy) · pdf · html · pptx · source code and config
(py js ts go java rs sh sql yaml toml …) · agent rule files
(AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules)
A document is not the only untrusted input an agent reads. A Code LLM builds its context from open
files, imported modules, pasted snippets and comments — and an instruction planted in a comment
is indistinguishable from the developer's own, because it wears the same marker (# TODO:,
# INSTRUCTION:, # TASK:). arXiv:2610.05139 measured what that
costs: 77.4–92.3% of completions contained a medium-or-higher weakness when the comment carried an
insecure objective, against 1.7–5.1% for ordinary coding work. aiitg answers upstream of generation —
the directive never reaches the model intact.
aiitg scan services/account.py # DET-008 CRITICAL @ account.py:10, exit code 1
aiitg hook pretooluse < payload.json # deny (POL-001): the file was not sent to the model- A hit needs a directive frame (marker / addressed-to-the-model / imperative opener / authority claim / "skip the check") and an insecure objective from the ten injected-weakness classes of the paper's Appendix A, plus prompt injection aimed at the assistant itself.
- Descriptive prose that merely names a sink ("a bare
subprocesscannot answer") is not an order and is not reported; neither is a prohibition ("… must never callchild_process") or a fix-note. - CRITICAL = an order to build one of those weakness classes (blocks the read) · HIGH = an order in a class respectable code also mentions, e.g. logging a secret (blocks) · MEDIUM = a marker-only mention (reported, quarantined and defused, not blocked).
- On quarantine the directive is replaced by an inert comment — same syntax, same indentation — so the file stays valid and the model reads data instead of an instruction.
Measured on this repository's corpus and on live third-party source, with the boundaries that come with them:
| Measurement | Result |
|---|---|
| Attack corpus (10 classes × 3 framings + 2 agent-directed; 2 payloads verbatim from the paper) | 32/32 detected, 29 CRITICAL / 3 HIGH |
| Benign corpus (46 ordinary work comments, five syntax families) | 0 false positives |
Ambiguous corpus (4 legit notes byte-shaped like a payload, pinned in tests/) |
4/4 reported — accepted false positives, the deliberate fail-closed choice |
Live third-party source probe (scripts/probe_comment_directive.py: this project + takumi/packages, build output excluded: 107 files, 750 comment lines) |
0 findings |
Honest boundaries: the paper's 700-item corpus was not released with its HTML, so these figures are
not a reproduction of the paper's numbers; a deterministic pattern detector is incomplete by
construction (the paper's own strongest screen still missed ~⅓); and DET-008 reads instructions, not
the vulnerable code a model may already have written — that is a SAST job. The ambiguous row is
reported as a known false-positive class, not as detections. Design notes, the full measurement method
and the known gaps:
docs/comment-directive-coverage.md.
A slide's speaker notes are not shown when the deck is presented, but they live in the file and every
extractor — and therefore every LLM that summarises the deck — reads them. pptx already parsed them;
until DET-009 nothing scored them, so a deck whose only injection was a plain-text instruction in
the notes scanned clean and was allowed. DET-009 closes that channel: a note that tries to instruct a
reader (a model or a human) is reported — HIGH for an explicit override/"do not tell" payload, MEDIUM
for a system-addressed or verdict-directing one — and the notes run is defused on quarantine. Generic
reminders ("update the roadmap slide") are not reported. Like DET-008 it is a pattern detector: the
channel is covered, the payload vocabulary is not exhaustive.
The hook runs as a separate process on every enforced tool call, so aiitg must be on PATH — a
hook command that cannot start is a non-blocking error in Claude Code, and the document would pass
through unscanned. Install it as a tool, not into a project venv:
uv tool install git+https://github.com/sscodeai/aiitg
# or
pipx install git+https://github.com/sscodeai/aiitgThen wire it up and verify:
aiitg hook-config # paste the snippet into .claude/settings.json
aiitg hook doctor # confirm it is wired AND startableuv venv .venv && uv pip install -e ".[dev]"
# or
python3.11 -m venv .venv && .venv/bin/pip install -e ".[dev]"aiitg scan report.docx --format json # JSON evidence report
aiitg scan report.xlsx --min-severity high # only high+ findings
aiitg scan report.pdf --format rich # terminal table
aiitg scan ./inbox --recursive --jsonl # batch directory scan, one compact JSON object per line
aiitg list-detectors # list all 9 detectorsExit codes: 0 = clean · 1 = findings at/above threshold · 2 = usage error · 3 = parse error (CI-friendly).
aiitg sanitize report.docx # strip hidden content → safe text
aiitg sanitize report.docx --mode redact # replace with [REDACTED]
aiitg trust report.docx # safe / caution / dangerousaiitg policy report.docx --format rich # allow / quarantine / human_approval / block
aiitg policy report.docx --audit audit.jsonl
aiitg audit audit.jsonl --format rich # append-only JSONL audit trail
aiitg approvals queue.jsonl # list pending approvals
aiitg approvals queue.jsonl --action approve --id <id>aiitg-mcp # stdio transport (Claude Code / Codex / OpenCode)
aiitg-mcp --transport http # streamable HTTPAgents call policy_file before feeding untrusted content into context; anything non-allow gets blocked/sanitized/human-approved.
The hook enforces input trust for tools that never call the MCP server or the library: the agent keeps
calling Read, and the document is scanned, labeled, and blocked/quarantined before it reaches the
model context.
aiitg hook-config # print the .claude/settings.json snippet (never writes it){
"hooks": {
"PreToolUse": [
{"matcher": "Read", "hooks": [{"type": "command", "command": "aiitg hook pretooluse", "timeout": 30}]}
]
}
}Behaviour: a clean document is read normally; a dangerous one is denied with the rule id, trust
label, and the exact evidence location; a quarantine decision rewrites the read to a sanitized
.txt substitute and tells the model, in additionalContext, that the bytes are sanitized text (the
"assume compromise" cost, surfaced rather than hidden). fail_closed=True by default means documents
aiitg cannot parse (.doc, .rtf, ...) are denied instead of trusted, and any hook crash becomes
an explicit deny with exit 2 — a crash must never fail open, because a non-blocking exit lets the raw
file through. Add --audit audit.jsonl to reuse the shipped append-only audit log, and
--queue queue.jsonl to route human_approval decisions into aiitg approvals.
Measured on a docx: a cache miss costs ~450 ms, a cache hit ~90 ms (same as the process-startup floor),
because the (path, mtime_ns, size) decision cache short-circuits before the pipeline runs.
Verify the wiring before trusting it — a hook command that cannot start is a non-blocking error in Claude Code, so documents would reach the model unscanned while everything looks configured:
aiitg hook doctor # executable on PATH, hook actually wired, directories writableThe decision cache and the audit log are local state, not a trust boundary — an attacker with
write access to your home directory can forge a cached verdict, exactly as they could edit your
settings. The cache is keyed on (path, mtime_ns, size) plus a namespace covering the build,
sanitizer mode, detector set and policy rules, so an upgrade or a --mode change can never reuse an
older verdict; a damaged entry is treated as a miss, never as a decision.
from aiitg import process_file
result = process_file("report.docx")
if result.is_blocked:
human_review(result.report) # escalate to approval queue
elif result.decision.action.value == "quarantine":
llm_context = result.sanitized.text # sanitized text is safe
else:
llm_context = result.sanitized.text # allow- External content is data, never authority. Documents are parsed, sanitized, and policy-checked before ever becoming instructions.
- Assume compromise. Detection is never 100%; isolation + least privilege + evidence + human approval + audit are the real defense.
- Detector/format decoupling. Detectors consume a format-agnostic
ParsedDocument; new format = new parser, new attack = new detector. - Coordinate-level evidence. Every finding carries exact location (paragraph/run/sheet/row/page/char_range) for forensics and sanitization.
- Conservative trust caps. Any evidence of deliberate concealment can never yield
safe; HIGH/CRITICAL concealment can never yield better thandangerous.
- arXiv:2507.06185 — Hidden Prompts in Manuscripts Exploit AI-Assisted Peer Review
- arXiv:2508.20863 — Misleading LLMs in Peer-Reviewing via Hidden Prompt-Injection
- arXiv:2509.09912 — When Your Reviewer is an LLM: Prompt Injection Risks in Peer Review
- arXiv:2605.28999 — Real-World Prompt-Injection Attacks in LLM-based Resume Screening
- arXiv:2610.05139 — Hidden in the Comments: A Context-Injection Attack Surface in Code LLMs (Munir, Quinzan, Roberts — University of Oxford): an insecure objective as an ordinary
# TODO:comment drove 77.4–92.3% of completions to a medium-or-higher weakness vs 1.7–5.1% for ordinary coding work, and the strongest post-generation screen still missed roughly one third. aiitg's answer is upstream of generation — see the DET-008 section below.
make test # 339 tests
make lint # ruff
make typecheck # mypy
make build # build wheel/sdist
make demo # generate and scan a temporary malicious sampleAll three gates run in GitHub Actions on every push to main and every pull request
(.github/workflows/ci.yml, Python 3.11 and 3.12) — the test badge above is only a local count, the
CI badge is the live status.
Malicious test fixtures are generated in code (never committed as binaries) — reproducible, auditable, diff-friendly.
- M0 — Hidden content audit (6 formats × 7 detectors → coordinate-level evidence)
- M1 — Sanitization + trust labeling (scan → sanitize → label closed loop)
- M2 — Policy enforcement + audit + human approval + MCP server (agent gateway)
- M3 — Zero-touch adoption: Claude Code
PreToolUse/PostToolUsehook adapter (aiitg hook) - M4 — Code-context coverage:
text/code/rule-file parsing, DET-008 (comment_directive), defusing in the sanitizer, hook enforcement for source and rule files - More formats (legacy
.doc,.ppt, images via OCR) · multi-policy engine · distributed audit · benchmark dataset for hidden-injection detection - A published evaluation set for DET-008 — the current numbers are measured on the corpus in
tests/plus a false-positive probe over live third-party source, not on the paper's (unreleased) 700-item corpus - Verified code (SAST) side: DET-008 reads instructions, not the vulnerable code a model may already have written
- M3.1 — HTTP proxy / reverse-proxy interception for API clients, per-tool
updatedToolOutputadapters, Bash heuristics
Design notes, measured findings and known gaps for M3 live in
docs/plan-zero-touch-adoption.md. Contribution rules and the
threat model are in CONTRIBUTING.md and SECURITY.md.
MIT
