Validate and evaluate math using python3 to prevent hallucinated calculations. Two modes: (1) bare arithmetic (numbers + operators only) via a safe AST evaluator — no shell interpolation, no eval; (2) scientific computing (imports, scripts, dataframes) gated by a machine-enforced static checker over an approved-modules allowlist for scipy, numpy, pandas, sympy, statistics, etc., then run under OS confinement. Use whenever performing, verifying, or double-checking ANY calculation, including: intermediate math inside a fact-check ("does 2.3M / 41 ≈ 56k?"), cost estimates ("$0.003 × 1.2M tokens"), unit conversions, percentage changes, t-tests, p-values, confidence intervals, CSV/JSON aggregations, regression coefficients, and sanity checks in code review. Default to using this skill — mental math is the exception, not the rule. Trigger phrases: "compute", "calculate", "what's X% of Y", "how many", "verify this number", "check the math", "is this right", or any expression with operators (+, -, *, /, %, **) on non-trivial operands.
Install
npx skillscat add magfrump/claude-workflows/arithmetic-eval Install via the SkillsCat registry.
On bad output, see guides/skill-recovery.md
Arithmetic Eval
Exact-math tool available. Use it instead of mental math. Writing a number that is a calculation result — even "simple" like 12 * 365 or 15% of 240 — run it through this skill first. Mental arithmetic is the most common source of hallucinated facts in otherwise-careful work.
Security model (read once)
The expression or script you evaluate is often attacker-influenceable — it comes from a document being fact-checked, a user claim, or code under review. Treat it as untrusted data.
- Never interpolate untrusted text into shell or Python source. Feed it as data through a quoted heredoc (
<<'EOF'), which disables all shell expansion of the body. Never paste an expression into anEXPR='...'assignment — a single quote in the input breaks out and executes as shell. Caveat: quoting disables body expansion, but it does not change delimiter matching — a body line equal to the delimiter word still terminates the heredoc early, and every line after it runs as shell (RCE). A per-invocation random delimiter does not fix this in bash: the delimiter word after<<undergoes quote removal but is never parameter-expanded, so<<"$D"matches the literal line$D(not the random value of$D) — verified empirically. Mitigation (enforced by you, before the shell ever parses the block): reject the untrusted input if any of its lines equals the delimiter word, and give each heredoc a distinct delimiter (see Mode 2) so one crafted payload cannot desync the three consecutive writes. - Each invocation is self-contained. Shell state (variables) does not survive between separate command runs, so every block below writes what it needs and runs it in one shot — there is no "setup once, use later" step to forget. Mode 2 uses a fresh
mktemp -dper run and deletes it after, so a poisoned helper can never carry into the next call. - Mode 1 is sound; the Mode 2 static gate is only a speed-bump. Mode 1 evaluates through an AST allowlist that can only produce a number — no calls, names, or imports — a hard boundary. Mode 2's
check.pyraises the bar (rejects non-approved imports and dangerous patterns) but a denylist over Python-with-scientific-modules cannot be made sound. The OS sandbox is Mode 2's actual boundary; the gate is defense-in-depth in front of it.
Residual risk (Mode 2): even under
bwrap, an approved module can read files visible on the read-only rootfs and print them — network exfiltration is blocked, disclosure-to-transcript is not. Do not point Mode 2 at paths outside the data you were asked to compute over. When neitherbwrapnorunshare -rnis available, reads are unconfined, so the runner fails closed — it refuses to run rather than warn-and-proceed. SetARITH_ALLOW_UNCONFINED=1to override, and only for input you trust.
Mode 1: Bare Arithmetic
Simple numeric expressions, no imports or function calls. The expression is inert stdin (quoted heredoc); the evaluator is a fixed program that can only walk a numeric AST. Self-contained — paste and run. Before pasting, check the untrusted expression contains no bare line equal to the delimiter EXPREOF; if it does, reject it (do not paste) — a matching line would close the heredoc early and run the rest as shell.
( ulimit -t 5 -v 1000000 2>/dev/null; timeout 5 python3 -c '
import ast, sys, math, operator
sys.setrecursionlimit(10000) # allow long flat sums; RecursionError is caught below
if hasattr(sys, "set_int_max_str_digits"): sys.set_int_max_str_digits(60000)
OPS = {ast.Add:operator.add, ast.Sub:operator.sub, ast.Mult:operator.mul,
ast.Div:operator.truediv, ast.FloorDiv:operator.floordiv,
ast.Mod:operator.mod, ast.Pow:operator.pow,
ast.USub:operator.neg, ast.UAdd:operator.pos}
MAX_BITS = 100000 # ~30k digits; the ONLY size cap — bounds the result, so a
# small-base big-exponent like 2**5000 is fine but 2**100000 isn't
def ck(x): # every intermediate: reject complex, non-finite, and oversized ints
if isinstance(x, complex): raise ValueError("complex result not supported")
if isinstance(x, float) and not math.isfinite(x): raise ValueError("non-finite result (overflow or nan)")
if isinstance(x, int) and x.bit_length() > MAX_BITS: raise ValueError("result too large")
return x
def ev(n):
if isinstance(n, ast.Expression): return ev(n.body)
if isinstance(n, ast.Constant):
if isinstance(n.value, (int, float)) and not isinstance(n.value, bool): return ck(n.value)
raise ValueError("non-numeric literal")
if isinstance(n, ast.UnaryOp) and type(n.op) in OPS:
return ck(OPS[type(n.op)](ev(n.operand)))
if isinstance(n, ast.BinOp) and type(n.op) in OPS:
l, r = ev(n.left), ev(n.right) # each child evaluated exactly once
if isinstance(n.op, ast.Pow):
# Predict result size BEFORE computing so a huge int power can't be
# materialized (ck() catches everything else, incl. float overflow).
if isinstance(l, int) and isinstance(r, int) and r > 0 \
and l not in (0, 1, -1) and r * l.bit_length() > MAX_BITS:
raise ValueError("result too large")
return ck(OPS[type(n.op)](l, r))
raise ValueError(f"disallowed: {type(n).__name__}")
# Strip thousands separators (1,200,000 → 1200000); ^ is an alias for **.
src = sys.stdin.read().replace("^", "**").replace(",", "")
try:
result = ev(ast.parse(src, mode="eval"))
out = f"[arithmetic-eval] {src.strip()} -> {result}" # str(result) inside try
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError,
RecursionError, MemoryError) as e:
print(f"[arithmetic-eval] REJECTED — {e}"); sys.exit(1)
print(out)
' ) <<'EXPREOF'
3600 / 0.003 * 1000
EXPREOF
# → [arithmetic-eval] 3600 / 0.003 * 1000 -> 1200000000.0A rejection prints a tagged [arithmetic-eval] REJECTED — … and exits non-zero — never a bare traceback. Do NOT fall back to mental math; fix the expression or report the error.
Mode 2: Scientific Computing
Imports, function calls, multi-line scripts (t-tests, CSV loading, aggregations). One self-contained block: it writes the script and two helpers into a throwaway mktemp -d, statically gates the script, runs it under the strongest OS sandbox available, and cleans up. Replace <script here>.
Results come back via stdout only — print your answer with the [arithmetic-eval] tag. The sandbox has no writable access to your working directory, so Mode 2 cannot deliver output files (a savefig/to_csv to a real path is blocked or lands in ephemeral scratch). It verifies numbers; it does not produce artifacts.
The three heredocs below use distinct delimiters (AE_SCRIPT_EOF, AE_CHECK_EOF, AE_CONFINE_EOF) so a crafted payload cannot break out of the untrusted script.py write and desync (rewrite) the check.py/confine.py writes that follow. Before pasting, check the untrusted script contains no bare line equal to AE_SCRIPT_EOF; if it does, reject it — a matching line would close that heredoc early and run the rest as shell.
AE=$(mktemp -d "${TMPDIR:-/tmp}/aeval.XXXXXX"); trap 'rm -rf "$AE"' EXIT
cat > "$AE/script.py" <<'AE_SCRIPT_EOF'
<script here>
AE_SCRIPT_EOF
cat > "$AE/check.py" <<'AE_CHECK_EOF'
import ast, sys
# pathlib is approved for READS; its write methods are blocked below and at runtime.
# operator is approved EXCEPT attrgetter/methodcaller (reflection → RCE, banned below).
# sys is NOT approved: sys.modules['importlib'].import_module(...) reaches any
# non-approved module and defeats this whole gate. os/subprocess/socket/shutil are
# likewise excluded — process exec / net / fs-write are contained at runtime.
ALLOWED_MODULES = {
"math","statistics","scipy","numpy","pandas","sympy","csv","json",
"fractions","decimal","datetime","pathlib","re","itertools","functools",
"collections","operator","openpyxl","matplotlib",
}
BAD_NAMES = {"eval","exec","compile","__import__","__builtins__","getattr","setattr",
"delattr","globals","locals","vars","input","breakpoint","memoryview"}
# This gate targets CODE-EXECUTION / RCE surfaces only. Filesystem writes and
# network egress are handled at RUNTIME (bwrap read-only rootfs; confine.py on the
# unshare/fallback tiers) — so write methods like .write_text/.mkdir are NOT listed
# here (that over-rejected benign scripts). Generic names (load/rename/replace) are
# also excluded — they'd reject json.load / df.rename. And .eval/.query (pandas)
# grant no escalation inside an already-sandboxed script, so they stay allowed;
# bare eval/exec remain banned via BAD_NAMES.
BAD_ATTRS = {
"read_pickle", # pandas pickle deserialization RCE
"sympify","parse_expr", # sympy eval paths
"attrgetter","methodcaller","import_module", # reflection → RCE
"check_output","Popen","getoutput", # subprocess (names ≠ data columns)
# Lowercase process-exec names (system/popen/fork/spawn*/exec*) are NOT here:
# they collide with DataFrame column access (df.system). os/subprocess are
# import-blocked, and process exec is blocked at RUNTIME by confine.py.
}
# Dunder strings used in reflection escapes — blocked as string literals so
# getattr(x, "__globals__")-style access can't sneak through. __main__/__name__
# are allowed (the standard entry idiom).
BAD_DUNDER_STRINGS = {
"__globals__","__builtins__","__import__","__class__","__bases__",
"__subclasses__","__mro__","__dict__","__getattribute__","__getattr__",
"__code__","__closure__","__func__","__self__","__loader__","__base__",
"__reduce__","__reduce_ex__","__subclasshook__","__init_subclass__",
}
def fail(msg, node=None):
ln = f" (line {node.lineno})" if getattr(node, "lineno", None) else ""
print(f"[arithmetic-eval] REJECTED — {msg}{ln}", file=sys.stderr); sys.exit(1)
try:
src = open(sys.argv[1]).read()
except OSError as e:
fail(f"cannot read script: {e}")
try:
tree = ast.parse(src)
except SyntaxError as e:
fail(f"syntax error: {e}")
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
if a.name.split(".")[0] not in ALLOWED_MODULES:
fail(f"import of non-approved module '{a.name}'", node)
elif isinstance(node, ast.ImportFrom):
root = (node.module or "").split(".")[0]
if node.level or root not in ALLOWED_MODULES:
fail(f"import from non-approved module '{node.module}'", node)
elif isinstance(node, ast.Name) and node.id in BAD_NAMES:
fail(f"use of banned name '{node.id}'", node)
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
# Dunder-string reflection only. URL literals are NOT rejected here
# (too many false positives in data/labels); egress is contained at
# runtime instead — hard on the bwrap tier (net namespace), best-effort
# on the others (confine.py monkeypatches, which are not exhaustive).
if node.value in BAD_DUNDER_STRINGS:
fail(f"dangerous dunder string literal '{node.value}'", node)
elif isinstance(node, ast.Attribute):
if node.attr in BAD_ATTRS:
fail(f"dangerous attribute/method '.{node.attr}'", node)
# Only the escape-relevant dunders (not all — allow .__version__/.__len__).
if node.attr in BAD_DUNDER_STRINGS:
fail(f"dangerous dunder access '.{node.attr}'", node)
elif isinstance(node, ast.Call):
f = node.func
# numpy allow_pickle via a TRUTHY 3rd positional: np.load(file, mmap, allow_pickle).
# Require a truthy literal so unrelated 3-arg .load(a,b,c) helpers aren't rejected.
if isinstance(f, ast.Attribute) and f.attr == "load" and len(node.args) >= 3 \
and isinstance(node.args[2], ast.Constant) and bool(node.args[2].value):
fail("load() with truthy 3rd positional (numpy allow_pickle RCE)", node)
# open() mode is NOT gated here — writes are blocked at runtime (read-only
# rootfs / confine.py), so a variable mode isn't a false-rejection risk.
for kw in node.keywords:
if kw.arg == "allow_pickle": # fail closed: anything not literal False
safe = isinstance(kw.value, ast.Constant) and kw.value.value is False
if not safe: fail("allow_pickle not provably False (deserialization RCE)", node)
sys.exit(0)
AE_CHECK_EOF
cat > "$AE/confine.py" <<'AE_CONFINE_EOF'
# Best-effort Python-level confinement for tiers without a mount namespace
# (fallback, and unshare -rn). Neuter network egress and block filesystem writes
# OUTSIDE scratch dirs — scratch stays writable so approved modules can write
# caches (matplotlib) and outputs; host files are protected; reads are unrestricted.
import builtins, io, socket, os, sys, runpy, tempfile
_WRITABLE = tuple(sorted({os.path.realpath(p) for p in (
tempfile.gettempdir(), os.environ.get("TMPDIR",""), os.environ.get("MPLCONFIGDIR","")
) if p}, key=len, reverse=True))
def _ok(path):
try: rp = os.path.realpath(path)
except Exception: return False
return any(rp == w or rp.startswith(w + os.sep) for w in _WRITABLE)
_real_open = builtins.open
def _ro_open(file, mode="r", *a, **k):
m = mode if isinstance(mode, str) else "r"
if any(c in m for c in "wax+") and not _ok(file):
raise PermissionError(f"write outside scratch blocked (fallback tier): {file!r}")
return _real_open(file, mode, *a, **k)
builtins.open = _ro_open
io.open = _ro_open # pathlib/Path.write_text route through here
def _no_net(*a, **k):
raise OSError("network disabled (fallback tier)")
try:
socket.socket.connect = _no_net # also affects `from socket import socket`
socket.socket.connect_ex = _no_net
# Block connectionless egress too: a UDP socket can sendto((ip,port)) with no
# connect and no DNS, so patching connect/getaddrinfo alone leaves a hole.
socket.socket.sendto = _no_net
socket.socket.sendmsg = _no_net
socket.socket.sendall = _no_net
socket.socket.send = _no_net
except (TypeError, AttributeError):
pass
socket.create_connection = _no_net
socket.getaddrinfo = _no_net # kills hostname resolution → most egress
_real_osopen = os.open
def _ro_osopen(path, flags, *a, **k):
if flags & (os.O_WRONLY|os.O_RDWR|os.O_CREAT|os.O_APPEND|os.O_TRUNC) and not _ok(path):
raise PermissionError("os.open write outside scratch blocked (fallback tier)")
return _real_osopen(path, flags, *a, **k)
os.open = _ro_osopen
def _guard(fn):
def w(*a, **k):
for p in a[:2]: # target (and dest for rename/link)
if isinstance(p, (str, bytes, os.PathLike)) and not _ok(p):
raise PermissionError("filesystem write outside scratch blocked (fallback tier)")
return fn(*a, **k)
return w
for _n in ("remove","unlink","rename","replace","rmdir","removedirs",
"mkdir","makedirs","chmod","symlink","link","truncate"):
if hasattr(os, _n): setattr(os, _n, _guard(getattr(os, _n)))
def _no_exec(*a, **k):
raise PermissionError("process execution blocked (fallback tier)")
for _n in ("system","popen","fork","forkpty","execl","execle","execlp","execlpe",
"execv","execve","execvp","execvpe","spawnl","spawnle","spawnlp","spawnlpe",
"spawnv","spawnve","spawnvp","spawnvpe","posix_spawn","posix_spawnp"):
if hasattr(os, _n): setattr(os, _n, _no_exec)
try: # neuter subprocess even if reached by reflection
import subprocess
for _n in ("run","call","check_call","check_output","getoutput","getstatusoutput","Popen"):
if hasattr(subprocess, _n): setattr(subprocess, _n, _no_exec)
except Exception:
pass
runpy.run_path(sys.argv[1], run_name="__main__")
AE_CONFINE_EOF
# Gate, then run under the strongest available OS confinement.
if python3 "$AE/check.py" "$AE/script.py"; then
s="$AE/script.py"
# CPU + address-space caps; wall-clock via `timeout`. No `ulimit -f` — it kills
# oversized writes with an untaggable SIGXFSZ; scratch is ephemeral anyway.
LIM='ulimit -t 10 -v 8000000 2>/dev/null'
MPL="$AE/mpl"; mkdir -p "$MPL" # matplotlib cache under $AE → cleaned by the trap
export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMEXPR_NUM_THREADS=1 MPLCONFIGDIR="$MPL"
ENV='--setenv PATH /usr/bin:/bin:/usr/local/bin --setenv HOME /tmp --setenv MPLCONFIGDIR /tmp/mpl --setenv OMP_NUM_THREADS 1 --setenv OPENBLAS_NUM_THREADS 1 --setenv MKL_NUM_THREADS 1 --setenv NUMEXPR_NUM_THREADS 1'
# Try tiers strongest-first; each only marks done on success, so a tier that
# FAILS to set up degrades to the next instead of silently dropping the result.
done=0
# Tier 1 — bwrap. Probe with the SAME mounts the real run uses. ORDER: tmpfs /tmp
# FIRST, then re-expose $AE read-only ON TOP of it (else the tmpfs shadows $AE and
# hides script.py). CWD stays the caller's dir (read-only) so relative data reads
# work and match the other tiers; writes there fail (results come via stdout).
if command -v bwrap >/dev/null 2>&1 && bwrap --ro-bind / / --tmpfs /tmp --dev /dev --proc /proc --unshare-all --die-with-parent true 2>/dev/null; then
bwrap --ro-bind / / --tmpfs /tmp --ro-bind "$AE" "$AE" --dev /dev --proc /proc \
--chdir "$PWD" --unshare-all --die-with-parent --new-session --clearenv $ENV \
bash -c "$LIM"'; exec timeout 10 python3 "$0"' "$s" && done=1
fi
# Tier 2 — unprivileged `unshare -rn` (kernel net namespace) + confine.py.
if [ "$done" = 0 ] && unshare -rn true 2>/dev/null; then
unshare -rn bash -c "$LIM"'; exec timeout 10 python3 "$1/confine.py" "$2"' _ "$AE" "$s" && done=1
fi
# Tier 3 — no OS sandbox. confine.py best-effort blocks writes/net/exec (and never
# reads), so an
# untrusted script could still disclose local files to the transcript. FAIL CLOSED:
# refuse unless the operator vouches for the input via ARITH_ALLOW_UNCONFINED=1.
if [ "$done" = 0 ]; then
if [ "${ARITH_ALLOW_UNCONFINED:-}" = 1 ]; then
echo "[arithmetic-eval] WARNING: running UNCONFINED (reads not contained) — trusted input only." >&2
bash -c "$LIM"'; exec timeout 10 python3 "$1/confine.py" "$2"' _ "$AE" "$s"
else
echo "[arithmetic-eval] NOT EVALUATED — no OS sandbox (bwrap/unshare) available and reads are unconfined here; refusing a possibly-untrusted script. For trusted input, re-run with ARITH_ALLOW_UNCONFINED=1." >&2
fi
fi
else
echo "[arithmetic-eval] NOT EVALUATED (script rejected) — do NOT use an unverified number" >&2
fiApproved modules (enforced by check.py)
math, statistics, scipy, numpy, pandas, sympy, csv, json,fractions, decimal, datetime, pathlib, re, itertools, functools,collections, operator, openpyxl, matplotlib
(pathlib is approved for reads. operator is approved except attrgetter/methodcaller. sys/os/subprocess/importlib are not approved — they'd defeat the import gate by reflection.)
Blocked patterns (enforced by check.py, not by eyeballing)
The static gate targets code-execution surfaces; filesystem writes, network egress, and process execution are enforced at runtime (read-only rootfs under bwrap; confine.py on the other tiers). Statically rejected: non-approved imports; the names eval/exec/compile/__import__/__builtins__/getattr/setattr/delattr/globals/locals/vars/input/breakpoint/memoryview; sympy eval paths (sympify, parse_expr), operator.attrgetter/methodcaller, and import_module; deserialization sinks (read_pickle; np.load with allow_pickle truthy, or via a truthy literal 3rd positional arg); subprocess entry points (Popen/check_output/getoutput); and the escape-relevant dunders as attribute or string literal (e.g. __globals__, __class__ — but .__version__/.__len__ are allowed). Network egress is not blocked statically (URL false positives); open() modes and pandas .eval/.query are not gated either — writes/exec/egress are all contained at runtime, so those fail at execution rather than being over-rejected here. Note the np.load positional check requires a literal 3rd argument on purpose (so unrelated 3-arg .load(a,b,c) helpers aren't rejected): a variable-valued positional allow_pickle (e.g. allow=True; np.load(p, None, allow)) is therefore not caught statically — it's contained at runtime by the sandbox (pickle can execute code, but the process has no writable FS, no net, and no exec).
Data files are read from the caller's working directory. Prefer absolute paths — under the bwrap tier the working directory is read-only and a relative path resolves against it, so a data file that has moved will fail cleanly rather than silently. Read-mode open() can disclose local secrets to output; do not read paths outside the data you were asked to compute over.
Rules
- Always tag output: print
[arithmetic-eval]so invocations are traceable. - Never interpolate untrusted text into a command. Always feed expressions/scripts via a quoted heredoc (
<<'EOF'). Quoting disables body expansion but not delimiter matching: a body line equal to the delimiter word still ends the heredoc and runs the rest as shell. So reject any untrusted input that contains a line equal to the delimiter word before pasting, and note each Mode 2 heredoc uses a distinct delimiter (AE_SCRIPT_EOF/AE_CHECK_EOF/AE_CONFINE_EOF) so one payload cannot desync the three writes. A random delimiter is not a fix in bash —<<"$D"matches the literal line$D, not a random value. - Never skip validation: even for
2+2, run through the appropriate mode. - Each block is self-contained — there is no persistent setup; paste the whole block every time.
- On rejection: do NOT fall back to mental math. Fix the expression or report the error.
- Mode choice: only numbers and operators → Mode 1. Needs imports or function calls → Mode 2.
Examples
Example 1 — Mode 1: cost estimate inside a fact-check
User claim: "We spent ~$3,600 last month on inference at $0.003/1K tokens." → run the Mode 1 block with 3600 / 0.003 * 1000 as the heredoc body → 1200000000.0 (~1.2B tokens; sanity-check against logged usage).
Example 2 — Mode 2: t-test for a benchmark claim
User claim: "Variant B is significantly faster than variant A (p < 0.05)." → run the Mode 2 block with this <script here>:
from scipy import stats
a = [102, 98, 105, 101, 99, 103, 100, 104]
b = [92, 95, 91, 94, 93, 90, 96, 92]
t, p = stats.ttest_ind(a, b)
print(f"[arithmetic-eval] t={t:.3f}, p={p:.5f}")→ [arithmetic-eval] t=8.062, p=0.00001 — claim supported at p < 0.05.
Example 3 — When NOT to mental-math
Bad: writing "1.2M users × $4 ARPU = $4.8M/mo" without running the math.
Good: run 1.2e6 * 4 through Mode 1, then write the verified number.
Running this skill costs ~one bash invocation. A wrong number in a fact-check or cost estimate costs much more. When in doubt, run it.