Somebody uploads a strategy. It has to run on our machines, over our candle data, in the same process as other people's strategies. The question that decides the entire architecture is what that strategy is allowed to be.
If the answer is "a Python file", then you are hosting arbitrary code and the rest follows: a container per customer, seccomp, a read-only root, no network namespace, a CPU and memory cgroup, and an image you have to keep patched. That is real work and real money per customer, and at the end of it you are still one kernel bug or one mounted socket away from a bad afternoon. The container is a fence around a language that can express the attack.
The other answer is to pick a language that cannot express it. A strategy here is a YAML document — indicators to compute, derived columns, and named boolean rules — and the only free-form thing in it is an expression like:
abs(bb40.mid - bb40.lower) > close * 0.008
That string has to become a pandas boolean column. There is no eval() and no exec() in the path that does it. Python's own parser builds the tree; konis_core/strategy/expression.py walks that tree against an explicit node allowlist and refuses everything not on it. What follows is the enumeration, then three attacks and the exact line that stops each, then the three things this does not protect against.
The whole accepted grammar
First, _parse (expression.py:214) calls ast.parse(source, mode="eval"). That one flag removes every statement in the language before the allowlist even runs: import os is a SyntaxError, not a node to reject. What arrives at the evaluator is a single expression tree.
_Evaluator is an ast.NodeVisitor with exactly eight visit_* methods. Those eight node types are the language:
| AST node | accepted as | line |
|---|---|---|
Constant |
int, float, bool — nothing else |
expression.py:133 |
Name |
a column or param name, looked up in a dict | :138 |
Attribute |
flattened to a dotted column name | :141 |
BinOp |
+ - * / ** and & | |
:153 |
UnaryOp |
-x, +x, not x |
:159 |
BoolOp |
and, or |
:169 |
Compare |
exactly one of < <= > >= == != |
:176 |
Call |
a bare name from a six-entry table | :187 |
The operators are themselves allowlists, not "whatever ast defines". _BINOPS (:40–48) has seven entries; % is not one of them, so close % 2 is refused with operator Mod is not allowed — not because modulo is dangerous but because no strategy needed it, and an entry nobody needs is an entry that costs you review time. _COMPARE (:50–57) has six; in, is, and their negations are absent. visit_Compare also rejects chained comparisons outright (:176–181) because a < b < c on a column means something other than what it reads like.
Callable names are a six-entry table, FUNCTIONS at :115–122: abs, shift, min, max, crossed_above, crossed_below. Not a module, not a namespace — six keys mapping to six functions defined in the same file.
And then generic_visit (:198):
def generic_visit(self, node: ast.AST) -> Any:
raise ExpressionError(f"{type(node).__name__} is not allowed in an expression")
That is the default-deny. Every node type not in the table above — Subscript, Lambda, ListComp, JoinedStr, IfExp, NamedExpr, Starred, Dict, Await — lands here. Adding a node type to the language requires writing a method. Nothing is accepted by omission.
The part that does the real work: bb40.lower is not an attribute
A rule says bb40.lower. The obvious implementation is getattr(namespace['bb40'], 'lower'), and the obvious implementation is where sandboxes die, because getattr on a live Python object is a doorway to every other object in the process.
This is what visit_Attribute does instead (:141):
def visit_Attribute(self, node: ast.Attribute) -> Any:
# `bb40.lower` is one namespaced column name, never a Python attribute.
return self._lookup(_dotted_name(node))
_dotted_name (:202) walks the Attribute chain, collects the parts, and joins them with a dot into a single string. _lookup (:145) checks that string against self.columns, a plain dict, and raises if it is missing. There is no getattr in the file.
The namespace it checks against is built in runtime.py:96–153. OHLCV columns come straight off the candle frame (:122), params are floats, and a multi-output indicator is flattened at :139:
ns[f"{spec.key}.{output_name}"] = series
So bb40.lower is one dictionary key that happens to contain a dot. The dot is part of the name, not an operation. This is the difference between a policy that has to anticipate attacks and a structure in which the attack has no verb.
Three attacks
All of these are real output from the module, with a namespace of close and bb40.lower.
1. __import__('os') — the canonical eval escape.
ExpressionError: unknown function '__import__'. Allowed: abs, crossed_above,
crossed_below, max, min, shift
Refused at expression.py:190–193. visit_Call does not resolve names against globals or builtins; it does FUNCTIONS.get(node.func.id) against a six-entry dict and raises when that is None. There are no builtins in scope to shadow, because nothing ever puts builtins in scope.
The chained form is refused one step earlier:
__import__('os').system('id')
ExpressionError: only plain function names may be called
That is :188–189 — if not isinstance(node.func, ast.Name). A call whose callee is anything other than a bare name is rejected before its arguments are visited. The same line refuses (lambda: 1)().
2. close.__class__.__mro__ — the attribute hop that reaches the object graph.
ExpressionError: unknown column 'close.__class__.__mro__'. Available: bb40.lower, close…
Refused at :149. _dotted_name flattened the whole chain into the literal string "close.__class__.__mro__", and no such key exists in the dict. Notice what did not happen: close was never dereferenced, so __class__ was never reached, so the fact that close is a live pandas.Series never mattered. Dunder names get no special handling because they need none — they are misspelled column names.
Indexing the result dies before that:
close.__class__.__mro__[1]
ExpressionError: Subscript is not allowed in an expression
That is generic_visit at :199. Subscript has no visitor.
3. An attribute hop onto a live object — the version that does not use dunders at all. close really is a pandas.Series sitting in the namespace, and a Series has .values, .to_csv, .index.
close.values
ExpressionError: unknown column 'close.values'. Available: bb40.lower, close…
Same line, :149, and this is the one that shows the shape of the idea. The namespace holds live Python objects. An expression can reference them, arithmetic on them, compare them — and cannot reach a single one of their attributes, because the syntax that looks like attribute access was redefined as dictionary lookup. There is no allowlist of safe attributes to get wrong, and no denylist of dangerous ones to keep current.
One consequence worth naming: because the accepted grammar is small enough to walk, referenced_names (:227) can report every column an expression reads without evaluating it. loader.py:141–153 uses that to reject a rule that reads a column nothing defines, and to reject rules touching LOOKAHEAD_COLUMNS (schema.py:30) — values that are only knowable after the bar they sit on. Lookahead bias, caught at upload time by a static read of the expression. That check exists because the grammar is enumerable, not because anyone wanted a lookahead checker.
What this does not cover
Indicators are arbitrary Python, and that is on purpose. The expression language is closed; the vocabulary it calls into is not. indicators.py has a register decorator (:56) and a module-level INDICATORS dict, currently 37 entries — ema, rsi, bollinger, ichimoku, smc, and so on. Each is a Python function that calls talib or pandas. Rule 2 in that file's docstring is the honest statement of where the boundary really sits:
No new indicator without a caller. The vocabulary is the security boundary; every entry widens it. Add one when a strategy needs it.
A customer's document names an indicator; it does not supply one. Adding an entry to that dict is a code change that ships through review, and it should be reviewed as a security change, because it is one. The parser is not what keeps you safe if register is easy to reach.
A pathological expression still costs CPU. ** is in _BINOPS at :45, which means integer exponentiation is legal. Evaluating 2 ** 20000 in this language returns a Python int of 20,001 bits. The bit length of 2 ** n is n + 1, so 2 ** 999999999 is a billion-bit integer — around 125 MB written out — and CPython will not hand back control while it builds one. Nothing in the allowlist has any reason to refuse it: it is a BinOp with an allowed operator over two Constant ints.
loader.py:37–39 caps counts — 64 indicators, 128 derived columns, 16 rules per side — but a count is not a cost, and none of those caps bounds the work inside a single expression. This is a quota problem: a wall-clock budget and a memory cap on whatever runs the evaluation. It is not the parser's job and the parser cannot be made to do it.
One gap found while writing this. _Evaluator.visit is recursive, so nesting depth maps onto stack depth. An expression of close + 1 + 1 + … repeated 2,000 times raises RecursionError at evaluation. Worse, it passes validation first: referenced_names uses ast.walk, which is iterative, so it happily returns {'close'} and the loader accepts the document. And RecursionError is not an ExpressionError, so it slips past the except ExpressionError handlers in loader.py:144 and runtime.py:150 and :172 and arrives uncaught. About 200 terms is fine; 2,000 is not. That is a crashed worker rather than an escape, and the fix is a depth check in _parse — but it was not there, and describing the boundary accurately means saying so.
Also, obviously: none of this has any opinion on whether a strategy is a good idea. It bounds what a document can do to the process, nothing else.
The price
A closed vocabulary means a customer cannot invent an indicator. If someone wants a volatility measure that is not among the 37, they cannot write one — they have to ask, and we either add it for everybody or we do not add it. For a format that is meant to be a hosted product rather than a library, that trade is defensible; for a research tool where the whole point is trying something nobody has tried, it would be the wrong trade and a container would be the right one.
That is the actual choice. Not "sandbox versus no sandbox", but: are you willing to close the vocabulary? If yes, the expressiveness you give up buys you a language where the attack has no verb, and you get to run untrusted strategies in-process, sharing one candle frame and one indicator cache across all of them, with no per-customer infrastructure at all. If no, pay for the containers — and keep paying, because a fence needs maintenance and an enumeration does not.