Architecture
How Palisade turns source text into precise findings - and why it’s shaped this way.
The spine
┌────────────── language frontends (pluggable) ──────────────┐ source ────▶ │ Python (stdlib ast) JS/TS (tree-sitter, [js] extra) │ └───────────────────────────┬─────────────────────────────────┘ ▼ Normalized Taint IR - language-neutral nodes: assignments, calls (dotted paths), string joins, collections, branches, functions, classes ▼ ┌──────────── language-agnostic engine ────────────┐ │ taint propagation (bounded inter-procedural) │ │ sanitizer resolution & body verification │ │ partial-defense downgrade · confidence scoring │ └───────────────────────────┬──────────────────────┘ ▼ YAML rules (data) ──▶ findings ──▶ dedup ──▶ baseline diff ──▶ emitters (terminal / JSON / markdown) ──▶ exit codeThree load-bearing decisions:
- Frontend/IR split. A frontend’s only job is lowering source into the
IR. The engine never sees a Python
astnode or a tree-sitter node. This is proven, not aspirational: the JS/TS frontend landed with zero engine changes, and the same YAML rules match both languages. - Rules are data. Sources, LLM signatures, sinks, sanitizers, and partial defenses are dotted-path patterns in YAML, validated by a pydantic schema. New framework coverage is a rule PR, never an engine PR.
- Precision over recall, mechanically enforced. Every claim below has a must-NOT-flag test twin in the suite; the false-positive tests are the most important tests in the repo.
Package layout
src/palisade_sec/├── frontends/ # ast_python.py · tree_sitter_js.py (source → IR)├── ir/ # the normalized taint IR (model.py)├── engine/ # analyzer.py (taint), findings.py, taint.py├── rules/ # 5 builtin YAML rules + pydantic schema + loader├── report/ # terminal (rich) / json / markdown emitters├── scanner.py # file discovery, config, frontend dispatch├── baseline.py # fingerprints, baseline diff├── fix.py # deterministic remediation-plan templates└── cli.py # Typer CLI: scan · baseline · fixThe IR in one screen
Frontends emit a deliberately small vocabulary (ir/model.py):
- Expressions -
Const,VarRef(dotted path, alias-resolved),Member,Call(dottedfunc_path, args/kwargs, receiver),StrJoin(f-strings,%,.format,+,.join, template literals),Collection,Unknown(taint = union of children). - Statements -
Assign(targets incl.self.xand+xaugment),Return(withraisesflag),IfBranch(with guard metadata: test names/calls, negation, literal-membership, terminates), loops, try/with blocks. - Definitions -
FuncDef(params, decorators, allowlist-membership flag),Module(import aliases, class bases).
Anything a frontend can’t express folds into Unknown, which propagates
taint conservatively. Import aliases are resolved at lowering time, so
import subprocess as sp; sp.run(...) reaches the engine as
subprocess.run - in both languages (const {exec} = require("child_process")
becomes child_process.exec). In JS, this is spelled self so class-field
tracking and hierarchy resolution are shared.
How a finding is born
Walk the tutorial’s /report route through the engine:
- Entry. Every function is analyzed as an entry point with clean parameters (plus seeded param taints in library mode or for route-decorated handlers).
- Source. Evaluating
request.json["spec"]matches the rule source patternrequest.json(dotted-prefix matching:req.body.qmatchesreq.bodytoo). The variable now carries aSOURCEtaint that remembers its own file:line and snippet. - LLM boundary.
client.chat.completions.create(...)matches anllm_signaturespattern. Because aSOURCE-tainted value is among its arguments (even nested insidemessages=[{"content": spec}]), the call’s result carries anLLMtaint - provenance of both the source and the LLM call site travels with it. - Propagation. Taint flows through assignments, string building,
collections, comprehensions,
await, returns,self.xfields, accumulators (parts.append(tainted)),json.loads(...)["cmd"], and bounded inter-procedural calls (default 3 hops; project-local functions andself.methods are resolved, including through class hierarchies when unambiguous; abstract stubs propagate instead of swallowing taint). - Sanitizer resolution. A call matching a sanitizer pattern is judged,
not trusted:
trusted: truepatterns (pydanticmodel_validate, marshmallowschema.load,shlex.quote, …) - suppress on name match.- Name-heuristic patterns (
validate…,sanitize…,…allowlist) - suppress only if the resolved body shows an allowlist shape (the input looked up in a fixed allowed collection, an AST node-type allowlist over the parsed input, an enum-literal guard, anre.fullmatch-style strict validator call, or one level of delegation to such a body). A bare raise, a length check, or a denylist search of the input does not count. Otherwise the taint keeps flowing, taggedunverified_sanitizer→ the finding lands as MED “risky”. - Full sanitizers also include
int()/enum casts and literal-membership guards (if verb in ("list", "status")).
- Partial defenses never suppress. Denylists and confirmation gates
(
is_blocked(...),confirm(...)) tag the taint and downgrade the eventual finding to MED - because PAL’s denylist was bypassed in a disclosed CVE (CVE-2023-44467), and Open Interpreter’s confirmation gate is the only barrier by design. - Sink.
exec(code)matches a sink pattern. Sink specs carry shape guards:taint_args: [0](onlyexec’s code argument is dangerous - a tainted globals dict is not),require_kwargs: {shell: true}(subprocess.run([...])without it is safe),safe_if_extra_args(parameterizedexecute(q, params)is safe). A sink-named call that resolves to a real project function is followed instead - the true sink inside beats the name heuristic. - Emission & dedup. The finding carries the full trace from the taint’s
own provenance. Duplicates collapse by fingerprint; one vulnerability
(same source → same sink) is one finding even when several rules match
- the most specific rule wins.
Confidence maps from hop count (0–1 → HIGH, 2 → MEDIUM, ≥3 → LOW); severity comes from the rule, downgraded to MED when partial defenses or unverified sanitizers are on the path.
Precision decisions (the “won’t flag” contract)
| Situation | Verdict | Why |
|---|---|---|
| Constant developer prompt → LLM → exec | silent | no untrusted source; taint requires one |
| Untrusted input → sink with no LLM | silent | out of contract - that’s Bandit’s finding, not Palisade’s |
subprocess.run([...]) arg list | silent | safe sink shape |
cursor.execute(q, params) / pool.query(text, values) | silent | parameterized |
| LLM output only logged/printed/returned | silent | not a sink |
| pydantic/marshmallow validation on path | silent | trusted sanitizer tier |
| Verified project sanitizer (allowlist-shaped) | silent | body-verified |
| Denylist / confirmation gate | MED risky | bypassed in a disclosed CVE (LangChain PAL) |
| Sanitizer in name only (cosmetic transform) | MED unverified sanitizer | Vanna CVE-2024-5565 shipped through one |
tests/**, conftest.py, .venv, .gitignored | skipped | configurable (include_tests) |
The judgment layer (optional, bring-your-own endpoint)
The taint engine is deterministic. On top of it sits an optional judgment layer
that answers questions the engine cannot decide by dataflow alone. It is reached
only by map, audit, review, and redteam --execute, never by scan, and
only audit, review, and redteam --execute call out (they need the
palisade-sec[judge] extra plus an endpoint you configure).
IR ──▶ PROBE (map): inventory the AI surface (offline, deterministic) taint findings ─┐ agent tools ────┼─▶ CHECKS build grounded questions from verified facts │ excessive_agency (tools) · taint_exploitability (paths) ▼ JudgeBackend.ask(state, questions) # one batched call per artifact ├─ TypeSafe: typed answers with confidence (verified) └─ OpenAI-compatible: strict-JSON validated against a schema (best-effort, unverified) ▼ decision (pass/review/block) ──▶ review composes a postureThree properties keep it honest:
- Grounded. A check only ever asks about an artifact the IR verified exists
(a real tool, a real
source → LLM → sinkpath). The state sent to the endpoint is that verified fact, not raw files. - Two adapters, one interface.
judge/defines aJudgeBackendwith backend-neutralQuestion/Answertypes. TypeSafe returns typed answers (verified=True); a generic OpenAI-compatible endpoint is validated against a pydantic schema and labelled best-effort (verified=False). - The unverified ceiling. An unverified backend can never emit a BLOCK on judgment alone (it downgrades to REVIEW), never move a deterministic finding’s risk, and never manufacture a Critical posture. The same rule is enforced at the finding level and again at the aggregate.
Calibration of the judged signals is preliminary: measured on a 10-case seed
corpus (n=4 to 6 per signal), not a benchmark result; the judged layer stays
advisory (see corpus/judgment/RESULTS.md). The deterministic scanner’s
published precision is independent of them.
Judged output is deterministic within a single run and non-deterministic
across runs. review performs one judgment pass and exposes both the audit
view (audit_findings) and the composed posture from it, so within a run they
never disagree. Because the model is probabilistic, two separate invocations can
score the same finding differently; the posture score is not stable run-to-run,
and nothing here claims it is.
Multi-agent analysis (deterministic, offline)
On top of the IR, map builds an agent graph: nodes are agents (the tools
they hold and the dangerous capabilities those tools exercise), edges are
handoffs. Framework adapters read the topology from the OpenAI Agents SDK
(Agent(tools=, handoffs=)), LangGraph (add_node/add_edge, a node’s
capabilities taken from its function body), and CrewAI (Crew(agents=, process=)). From that graph, scan emits the deterministic PI-AGENT-HANDOFF
finding when untrusted input runs an agent that can hand off to a
dangerous-capability agent - no model, no network, and only on a complete
untrusted path (the same contract as the taint rules). Agents are scoped per
module, so same-named agents in different files are not merged.
Safety contract (non-negotiable)
- The scanner never executes, imports, or evals scanned code. Parsing
only (
ast.parse/ tree-sitter). A live test plants a file whose module-level code writes a marker and asserts the marker never appears. scanmakes no network calls and needs no API key, account, or telemetry. Nothing leaves your machine.- Writes are limited to
.palisade/and explicitly requested output files. - A file that fails to parse is skipped with a warning - never a crash. Invalid rules and configs are reported and skipped (pydantic-validated).
Scale characteristics
Measured on real repos (see proof-scans.md): 1,576 mixed Python+TypeScript files (Langflow 1.2.0, backend + frontend) in ~9 s on an Apple M3 Pro (re-measured for 0.5.1), zero crashes, zero skipped files, zero false positives. Inter-procedural depth is bounded (default 3 hops, configurable) and truncation is reported honestly in the scan notes.
Resolving calls across object boundaries
Framework code rarely calls the dangerous thing directly. It calls a method on an attribute, and what that attribute holds was decided somewhere else. So the engine resolves an attribute’s or a local’s type and follows the call through it, from three sources:
| Source | Shape |
|---|---|
| constructed | self.x = SomeClient(...), read from any method of the class |
| declared in the class body | driver: SqlDriver = field(...) - the normal case for attrs, pydantic and dataclass models |
| declared on a constructor parameter | def __init__(self, driver: SqlDriver) with self.driver = driver |
The resolved type also takes part in rule matching, so a call on an attribute
that holds a model object is recognized as an LLM call even though the call site
names only the attribute. And because a framework entry point is usually a
template method on an abstract base, the receiver’s type is carried into the
call, letting self.hook() inside a base class resolve to the subclass the
attribute actually holds.
Ambiguity is never guessed. Two same-named classes that both implement the method resolve to nothing; an import that names one of them resolves to that one. Guessing is how a taint path gets invented, and an invented path is a false positive.
What this does not reach, measured rather than assumed: paths whose capability is chosen at a wiring site in another file (the declared type is abstract, the concrete object is injected), and arguments assembled inside a container before being splatted into the call. Those need whole-program object tracking - following values, not types - which is the next capability on the roadmap.
Known limits (documented, not hidden)
- Receiver-name LLM signatures (
chain.run,llm.predict) match by variable naming convention; unusual names need a one-line custom rule. - Many-provider abstract dispatch (ten subclasses implementing
submit_prompt) stays unresolved by design - ambiguity is not resolved by guessing. ThePI-FRAMEWORK-EXECwrapper signatures cover the common cases. - Fully dynamic pipeline-object dataflow (PandasAI v2’s step runner) is beyond bounded static taint; framework-specific rules are the pragmatic path.
- Sanitizer body verification is a heuristic - it judges shape, not
semantics. It silences only on a recognized allowlist / strict-validator
shape (allowlist membership, an AST node-type allowlist,
re.fullmatch, enum-literal guard); an ambiguous or cosmetic body downgrades to MED “unverified sanitizer” rather than suppressing. A bare raise, a length or emptiness check, or a denylist search of the input is not an allowlist shape: 0.5.0 counted those as verified and silenced the finding; 0.5.1 downgrades instead. - The
mapcommand resolves a literalmodel=argument only; a model id held in a variable or module constant is reported as?. Offline-map only; it does not affect taint findings or the judgment layer. PI-AGENT-HANDOFFrun-site detection covers agent-variable invocation (Runner.run(agent, x),agent.run(x));crew.kickoffand compiled-LangGraph.invokeentry mapping, LangGraph conditional edges, and cross-module agent wiring are recall follow-ups, not precision gaps.map’s whole-project agent graph can merge same-named agents across files for display; thePI-AGENT-HANDOFFfinding is scoped per module and unaffected.