NLIR (Natural Language Intermediate Representation) is a research PoC for a security-focused IR for natural language. The premise of NLIR is to treat LLM input like a non-deterministic programming language, which enables one to see parsing and detection of malicious prompts as a compiler problem.
For more info, see its introductory blog post Hunting Prompt Malware with NLIR.
Disclaimer: This is a research-grade PoC with 100% agent-written code
The project finds prompt injection and concealed instructions. It safely unpacks Base64, hexadecimal, and URL-encoded text, as well as other custom encodings using an optional reasoning model. NLIR then lifts the source and each unpacked child into the same IR.
NLIR rules are like YARA rules for prompt meaning - they can be used to detect and hunt for semantic behavior.
The lifting can be summarized as such:
Afterwards rules can be run on the IR.
The full IR specification — entity types, opcodes, relation types, modality, evidence, and the exact model prompts — is in docs/IR.md.
NLIR requires Python 3.12 or later and uv.
uv sync
uv run pytest -quv run nlir scan file path/to/prompt.mdThe command prints JSON with source annotations. It does not call a model.
The library API is the primary interface. The CLI uses the same components.
Create a live lifting session from an explicit non-secret TOML file, lift a file, and run one rule on the result:
from nlir import NLIR
with NLIR.from_live_config("live.toml") as nlir:
rule = nlir.read_rule("rules/package-install.yaml")
lifted = nlir.lift_file("path/to/SKILLS.md")
report = nlir.run_rule(rule, lifted)lift_file returns a LiftedIR object and nothing else. NLIR never writes IR anywhere: keeping it is your responsibility.
from pathlib import Path
from nlir import LiftedIR
Path("lifted.json").write_text(lifted.model_dump_json(), encoding="utf-8")
restored = LiftedIR.model_validate_json(Path("lifted.json").read_text(encoding="utf-8"))
report = nlir.run_rule(rule, restored)LiftedIR is a Pydantic model. Put it in a file, a database, or a queue as you see fit.
run_rule also accepts several lift results at once, which is how you hunt everything you have kept:
report = nlir.run_rule(rule, [first_lifted, second_lifted])Use read_rule_dir to load all YAML rules from one directory.
Set NLIR_LIVE_API_KEY in your shell. Do not put an API key in the TOML file.
Create a local live.toml file:
base_url = "https://api.openai.com/v1"
model = "gpt-4o"
unpack_model = "gpt-4o"unpack_model is optional. When you set it, NLIR sends each source artifact to a separate reasoning-unpack request before lifting. That request creates only untrusted virtual children. It does not run commands or use tools.
Lift a file, print its accepted IR, and test one rule:
uv run nlir lift live path/to/SKILLS.md \
--config live.toml \
--show \
--test-rule rules/package-install.yamlThe --show option prints the accepted canonical IR for this command. The command writes nothing to disk.
Start the local browser app with the same live configuration:
uv run nlir web --config live.tomlOpen http://127.0.0.1:5000 in a browser.
The left panel takes a prompt and lifts it: the result is shown with each IR token highlighted by type, and hovering a highlight shows which token matched. The middle panel renders the lifted IR (entities, operations, relationships) as a graph. The right panel lists every local rule with its HIT / NO_HIT status; selecting a rule reveals its YAML source (hidden by default) and highlights its match in both the text and the graph.
The browser app uses the configured live and optional unpack models when you select LIFT. It does not store the prompt or its results.
Rules are YAML files. A rule has an ID, optional human metadata, selectors, and required conditions.
version: "1.0"
id: external-secret-transfer
metadata:
description: A secret is sent to a network destination.
author: Example author
references: []
select:
data:
any:
- entity:
type: SECRET
- entity:
type: CREDENTIAL
destination:
entity:
type: NETWORK_DESTINATION
where:
- direct:
from: data
to: destination
relation: SENT_TOany matches one selector variant. Use it when the same fact can have more than one valid IR type.
uses binds an operation to an entity that it reads, writes, sends, or targets. Its roles are actor, input, output, destination, and any. NLIR rules also support direct, trust_boundary, path, distance, sequence, modality, and decoded_from conditions — see docs/IR.md for the full condition reference and entity/opcode/relation vocabulary.
An entity selector's value field matches an exact literal. Use value_pattern instead to match a regular expression against an entity's value, for example a filename pattern or a substring like a path prefix.
Every operation also carries a modality (polarity, imperative, hypothetical, conditional, quoted, example, descriptive). A rule can require imperative: true and reject hypothetical, quoted, or descriptive matches, so that a negated or hypothetical mention of an attack does not fire the rule. See docs/IR.md.
See ./rules.
benchmark/manifest.json is a synthetic corpus of risky prompts, each paired with six near-miss variants (negation, hypothetical, quote, policy text, and so on) that use the same vocabulary but must not fire a rule. Replay it against your own configured model:
uv run nlir benchmark live --config live.tomlUse --family to run one attack family, and --rules-directory to test a different rule set.
Live tests call the configured service. They are off by default.
export NLIR_LIVE_API_KEY="..."
NLIR_LIVE_E2E=1 NLIR_LIVE_E2E_CONFIG=live.toml \
uv run pytest -m live_e2e -qUse these tests to measure model output quality.

