v0.6.0
A security and correctness release, and a large one. evaluate_sage now
runs SageMath rather than Python, caller code is checked against a
deny-by-default allowlist, and a run of sandbox escapes found over the course
of the work were each closed with a regression test. It is a minor bump
rather than a patch because caller-visible behaviour changes: 2^3 is 8,
x/y/z/t are predefined, and callers can no longer import, reach the
external CAS interfaces, or call show/view/latex/html.
Security
-
A forbidden attribute could be reached by alias. The rule fired only when
the attribute was the callee, sof = latex.has_file; f(payload)passed
validation and ran a shell — as did the list, tuple, dict and lambda-default
spellings. Reaching the attribute is the capability, so the check moved to the
attribute node. Older and wider than thelatexmethods:popen,rmtree
and thespawn*family had been guarded the same call-only way for far
longer. No new over-block — the doctest corpus still passes. -
Re-offering
latexhanded over a shell (remote code execution).
Latex.has_file(name)runscall("kpsewhich %s" % name, shell=True), so
latex.has_file('x; id > /tmp/x')executed a command as the container user;
check_fileandadd_package_to_preamble_if_availablereach it too. The name
had been re-offered on the reasoning thatlatex(...)builds a string — true
of the call, and not of the object, since allowlisting a name hands over every
method on it. The three methods are refused by name,latex(obj)and its
string-building methods still work, and the first, broader fix was rejected
because it refused 56 examples from SageMath's own doctests. -
A caller could reserve a name for a tool to fill. Binding a template's
internal in dead code (if False: _fig = 1) marked it as the caller's own, so
the object a later tool call built arrived under a name already exempt from
being withheld — a livematplotlibFigure, the BytesIO holding the plot PNG,
and the prelude's symbol table. No capability was reachable through them, but
holding trusted code's objects is the wrong side of the invariant. Whatever
trusted execution introduces is now withheld regardless of what the caller
claimed first — determined from the generated code's own AST as well as from
a namespace diff, since the diff alone is blind to trusted code overwriting
a name the caller had legitimately created, and the AST alone cannot see what
from sage.all import *brings in. -
write_*methods wrote caller-chosen files.
graphs.PetersenGraph().write_to_eps(path)and
Polyhedron().write_cdd_Hrepresentation(path)each wrote to disk — the same
capabilitysave*,dump*andexport*were forbidden for, under a name
none of them covered.writeis now the fourth forbidden attribute prefix;
plotting is unaffected, since the templates render through
.savefig(BytesIO)under the trusted policy, which clears these prefixes.
Found by auditing the factory guard rather than the code it guards: it had
been skipping every callable whose parameters are all optional, which hid 225
factories. The guard now covers anything callable with no arguments, matches
capability words against name segments rather than substrings, and accepts a
baseline of 37 mathematical collisions so anything new fails. -
A specialised tool call reopened the scrubbed namespace (remote code
execution). The generated prelude runsfrom sage.all import *in the same
persistent namespace as caller code, restoring every name the startup scrub
had removed.unpickle_globalis guarded by that scrub alone, so after any
tool call a caller who had bound the name in dead code could reach it:
unpickle_global('os', 'system')ran a shell as the container user. The
namespace is now resealed after trusted execution rather than only at
startup — both scrubs re-applied and the withheld set re-taken — because a
snapshot cannot cover names that appear later. Caller-created names are
preserved. The reseal runs in afinally, so it covers a tool call that
raises or is interrupted as well as one that succeeds: the prelude runs first,
so a failing call has already repopulated the namespace by the time it fails,
and sealing only on success left every failing call holding the door open. -
A caller binding can no longer authorize a name that already exists.
Binding is judged statically, soleaked = smuggled(); smuggled = None
authorized readingsmuggledat the start of the module, where it still held
whatever the namespace had put there — a preloaded object from a custom
SAGEMATH_MCP_STARTUPexecuted. Splitting it across two calls worked too,
with the binding in a statement that raised before assigning. The rule is now
general: a name that is live but not offered is refused whatever authorizes
it, which is what the earlier dunder-only fix should have been. Not reachable
by an untrusted caller on a default deployment, since the startup is operator
configuration — but the same hole opens with no custom startup at all if a
SageMath upgrade lands beforemake allowlistis rerun. -
The rich-output subsystem is fully closed.
get_display_managerand
pretty_printwere the last live names fromsage.repl.rich_output, joining
showandview— removed by provenance this time, which also took
DisplayManagerandrestricted_output. The manager hands back an object
carryingswitch_backendandgraphics_from_save; neither is exploitable on
10.9 (no backend class is reachable, andgraphics_from_savecan only invoke
a callable the caller could already call), but none of it has a purpose over
MCP. Plotting andwant_latexare unaffected. -
A structural guard for objects returned by allowlisted factories. The
allowlist governs names; an object's methods are governed only by the
attribute rules, so a factory handing back a rich object is a route no name
check can see. A test now calls every allowlisted zero-argument factory and
fails if the result exposes a method matching a capability word. -
Sage's own string-path primitives are refused. The previous round blocked
Python'soperator.attrgetterand left SageMath's equivalents in place.
attrcall('save', path)(M),raw_getattr(M, 'save')(M, path)and
getattr_debug(M, 'save')(path)each wrote a real file, andgetattr_debug
is a fullgetattrequivalent that reached
__class__.__base__.__subclasses__(). A source scan cannot find this class of
helper — 807 of the 1902 allowlisted names are compiled Cython with no
readable source — so the fix is by provenance:sage.misc.call,
sage.cpython.getattrandsage.cpython.debugare scrubbed wholesale, which
also caughtgetattr_from_other_classanddir_with_other_classthat nobody
had named. -
make denylist. Adding a module to_DANGEROUS_SAGE_MODULESused to
remove nothing until a hand-maintained baked list was updated, and there was
no command to update it — which is whysage.misc.callwas added and
attrcallstayed reachable. The drift test now names the command. -
String-path attribute access is refused. Every attribute rule in this
server is enforced on the AST, andoperator.attrgettertakes its path as a
runtime string the AST never sees — so
operator.attrgetter("misc.persist.unpickle_global")(sage)returned the real
function, which is arbitrary code execution, and
operator.attrgetter("__builtins__")(warnings)returned the builtins dict.
Sage binds 22 module objects includingsageitself, so one such primitive
reaches the whole tree;getattr,setattrandvarswere already refused,
which leftoperatoras the only way in.attrgetter,methodcallerand
itemgetterare now forbidden calls andoperatora forbidden attribute
parent. -
A dangerous-module entry that removed nothing.
sage.libs.pari.allwas
listed afterpariwas found to run a shell, and contributed zero names: the
derivation takes only names defined in a module, and PARI's are defined in
cypari2. The removal that worked was the explicit one. An integration test
now fails on any provenance entry matching no names, so an entry that looks
like protection and is not cannot be added silently. -
pariexecuted shell commands.pari('system("id > /tmp/x")')wrote a
file as the container user. The scrub that removedgpandmaximaworks
fromsage.interfaces.all, and the PARI library interface comes from
sage.libs.pari, so it was never covered. -
oeisreached the network, andinstall_doc,show,view,animate,
htmlandlatexeach wrote to disk or read the installation. All are
removed from the caller namespace. Plotting and LaTeX output are unaffected:
the plot tools render through.savefig(BytesIO), andlatexis imported
fromsage.allinside the worker rather than read from that namespace. -
Caller code is checked against an allowlist. A name may be read only if
this server offers it: the mathematical names SageMath preloads, the safe
builtins, and whatever the caller defines itself -- including names created
earlier in the same session, which the worker reports so stateful use keeps
working. Everything else is refused. Seven bypasses in two days were each a
name nobody had forbidden, and this changes the default for the next one: a
helper a future SageMath adds is denied until someone reviews it. An
integration test, plus a weekly scheduled job, fails when the allowlist and the
installed Sage disagree.Tool parameters keep the previous rules and are not allowlisted -- they name
things valid in a template's context (HammingCodeinsidecodes.), and the
denylist, import ban and persistence rules all still apply to them. -
A caller binding can no longer authorize a dunder. Names the caller's own
code binds are trusted without consulting the allowlist, and binding is judged
statically —if False: __builtins__ = 1counts, as doesexcept ValueError as __builtins__, which never names the object. Every name live in the worker
namespace is allowlisted except nine dunders, and__builtins__['__import__'] ('os')is a shell. Reading a dunder was already blocked by its own rule, so
this was the second lock rather than the first; bindings now drop dunders so
the allowlist does not depend on a rule enforced elsewhere. The drift test
checks the shape of the gap rather than filtering it out of the comparison. -
Caller code can no longer import anything.
sage.*was allowlisted for the
generated prelude, and callers used it to re-import every helper the worker
namespace scrub had removed:from sage.misc.cython import compile_and_load
compiled and loaded a module,from sage.interfaces.gp import Gpspawned GP,
andunpickle_global('os', 'system')('id')ran a shell command. The allowlist
now belongs to the generated templates alone. Breaking for callers who
imported —import math,from sage.all import factorial— but the names are
already in the namespace without them. -
Caller code cannot write files.
.save()was blocked;.dump(),
.save_image()and.export_jmol()were not, and each wrote a real file.
Persistence is matched by prefix (save*,dump*,export*) for callers; the
plot templates keep.savefig(BytesIO). -
Sage's external CAS interfaces are no longer reachable.
gpandmaxima
both executed shell commands through their ownsystemescapes --
gp('system("id > /tmp/x")')wrote a file as the container user. Everything
sage.interfaces.allexports is removed from the worker namespace, so a
future Sage release adding an interface is covered without anyone updating a
list. The libraries are untouched: Gröbner bases still go through libsingular
and factoring through PARI, in-process. -
Sage helpers that execute, compile, fetch or write are removed by
provenance.cython(get_remote_file(url))was download, compile and execute
in one expression;sh()runs a shell;loadsis code execution from bytes.
Names from fifteen modules are stripped at worker startup, which covers the
helper nobody has thought of yet. Doing this by walking the namespace instead
forced every lazy import to resolve and put 1.8s into the first evaluation, so
it resolves only those modules. -
Forbidden functions are blocked through attribute chains.
sage.misc.sage_eval.sage_eval("__import__('os').getuid()")returned the
container uid: the name checks looked at bare names and call targets, and
sageis an allowed import root, so the same function was reachable one dot
further along. The final name is what is checked now, however it is spelled. -
load()andattach()are forbidden for callers. They execute whatever
path they are given andload()accepts a URL, so this was remote code
execution from a name no rule mentioned. -
docker compose upno longer publishes on every interface. The port
mapping was8314:8314, which binds all interfaces on the host; the server
evaluates code and authenticates nobody, so following the quickstart put an
unauthenticated Sage evaluator on the local network. It now publishes to
127.0.0.1:8314, and a test fails if that widens again. Every other default
was already loopback (stdio transport,--host 127.0.0.1,ClusterIP
service), which is what made the compose mapping stand out. -
USAGE.mdshowed--host 0.0.0.0without qualification. It is correct inside
a container, where the published port decides reachability, and wrong on a
host; that distinction is now stated where the command appears.
Changed
-
x,y,zandtare now predefined for caller code. Sage's REPL
predefinesxalone, but the specialised tools have always declared four in
their prelude, sodifferentiate_expression("x^2*y^3")worked while the same
mathematics throughevaluate_sagefailed. Both paths now read one constant
(symbols.PREDEFINED_SYMBOLS), with a test asserting they agree. Four and no
more:y,zandtare unbound in a fresh Sage namespace, whereasnand
iare numerical approximation and the Gaussian imaginary unit. A mistyped
ynow becomes a symbolic variable rather than an error, which is already
true ofxin Sage. -
evaluate_sagenow runs SageMath, not Python. Caller code goes through
Sage's preparser, as the Sage REPL does, so2^3is 8 rather than 1, integer
literals are SageIntegers, generator syntax likeK.<a> = NumberField(...)
parses, andxis predefined. The tool advertised "SageMath code" and executed
plain Python; five of the seven examples in its own description could not run.
The specialised tools have always preparsed viasage_eval, so the two halves
of the server disagreed about which language they accepted.This changes results for anyone relying on
^meaning XOR. Use^^for
XOR, as in Sage. Server-generated templates are deliberately not preparsed.Validation reads the preparsed source, so the sandbox is unaffected: payloads
hidden behind preparser-only syntax are rejected like any other.
Added
-
The specialised tools declare a symbol on sight, the way
SRdoes.
simplify_expression("w^2 + w^2")answers2*w^2where it used to fail, and
expand_expression("(θ + φ)^2")answers in the letters you wrote. The rule is
SageMath's own and it is two rules, not one:w + 1typed as code is a
NameErrorin Sage as it is here, whileSR("a*b + a")— a string parsed
into the symbolic ring — createsaandb. The tools take an expression as
a string, so they follow the second;evaluate_sageis code and still refuses
with the message that namesvar('w').Narrower than
SRin the way that matters.SRinvents any identifier, so
SR("sinn(x)")returnssinn(x)andSR("pi2*2")returns2*pi2— a typo
becomes a symbol and the caller gets a confident wrong answer. Only
symbol-shaped names are declared: a letter with an optional index, a
spelled-out Greek name, or a Greek letter.sinn,foobarandpi2are
still errors.Names SageMath already defines are never shadowed, which took two goes to get
right. The first version usedstr.isalpha()and declared a freshπ,
turningπ.n()into "cannot evaluate symbolic expression numerically" — the
generated check was more permissive than the_SYMBOL_SHAPEregex it was
meant to mirror, which is ASCII-only. The Greek letters come back through the
allowlist instead, soπ,σ,ζ,Γandψkeep their meanings — pi, the
divisor sum, zeta, gamma and digamma — and the other twenty-four declare. -
A sequence of matrices is laid out the way Sage lays it out.
repr
stacks a basis one matrix after another; Sage prints them side by side, in
columns, and for eight 3×3 matrices that is 4 lines against 32. It matters for
a server whose entire output is text, and it was invisible until the doctests
were executed rather than validated — it was the only class of disagreement
left in that suite. The layout is Sage's ownformat_listunder Sage's own
condition: an element has to opt in through_repr_option('ascii_art')or its
parent'selement_ascii_art. Matrices do, morphisms do not, and SageMath's
doctests record both — approximating that rule instead of reading it was wrong
twice before the gate was right. -
SageMath's doctests are executed now, not only validated.
tests/test_sage_doctest_execution.pyruns a deterministic sample of the
corpus through a real session and compares what comes back against the output
Sage documents, using SageMath's ownSageOutputCheckerso the...ellipsis
and# tolsemantics are the ones the corpus was written against. Measured
against 10.9: 400 docstrings, 2,163 examples, 1,259 comparable, 100%
agreement, no mismatches and no unexpected errors. It is opt-in and sampled
— 26 examples a second against 9,000 for validation, so the whole corpus is
about three and a half hours — and runs asmake doctest-execution, never on
the pull-request path.Five conventions had to be understood before the number meant anything, each
of which produced a false failure first: aTracebackblock is an assertion
rather than an error; excluding an example must mean do not compare and
never do not execute, or a later line is checked against a namespace that
never got the earlier one's effect;doctest:-emitted warnings arrive on our
stderr;# todo: not implementedmarks an answer that is deliberately wrong;
and Sage's REPL lays a sequence of matrices out in columns where this server
stacks them. The last is a real difference in what a caller sees and is on the
queue rather than hidden — the harness skips those comparisons so the gap is
measured. -
Two suites of the workload this server exists for.
test_numerical_workflows.pycovers floating point, where a model's answer is
not imprecise but confidently wrong: catastrophic cancellation in the
quadratic formula, a 12×12 Hilbert solve with no correct digit and a residual
that looks fine, Newton's quadratic convergence measured rather than claimed,
a grid-refinement check that the finite-difference Laplacian really is
second-order, the CFL limit crossed, Robertson's stiff kinetics returning NaN
under an explicit step, and a quadrature error estimate that is accurate about
the wrong domain.test_physics_workflows.pyruns sessions that end at a
number with an external referee — Wien's constant and the Sun's temperature
from Planck's law, the Stefan–Boltzmann constant to eleven digits, Mercury's
43″ per century from the Schwarzschild orbit equation, the oscillator ladder
by finite differences, the anharmonic oscillator where the perturbation series
goes negative and diagonalisation does not, phonon modes against the closed
form, Maxwell's equations on a plane wave, the Bohr radius and 1/α from CODATA,
a decay fit by two independent methods, and the double pendulum's energy
conservation and sensitivity. 17 tests, ~17s against SageMath 10.9. -
Every refusal without a security justification is gone, found by
categorising all 8,218 that SageMath's doctest corpus provokes and fixed
test-first with every bypass payload still refused. Corpus acceptance went
from 97.81% to 98.60% — 2,960 refusals removed, a 36% reduction — and the
allowlist gained exactly two names.latex(...)works again (1,387 refusals). It was scrubbed alongside
show/view/html, which write files; it builds a string.latex.eval()
runs the toolchain and is still refused, by the rule that forbidsevalas
an attribute.- A forbidden global no longer shadows an ordinary local or method (575).
A.trace()is the trace of a matrix,l.remove(x)is a list, anddb,
gap,maximaandshare what people call their variables. Those names
are absent from both the namespace and the allowlist, so an unbound read is
still refused and a caller's binding is their own value — now asserted, in
the unit suite and against the real namespace.sage.misc.sh.sh('id')and
sage.misc.trace.trace(code)are cut as attribute paths instead, which is
where they live. operator.leand the other arithmetic and comparison functions (206).
The module stays forbidden and a named subset is let through, so
Poset((divisors(30), operator.le))works whileoperator.attrgetter,
operator.setitemandm = operatordo not._holds the previous result (694), as in every REPL Sage ships. Caller
code only: a tool call in between cannot move it.eval,vars,localsandinputare usable as identifiers (38) —
an eigenvalue, a list of variables, an automaton's input word, a dictionary.
Each is absent from the restricted builtins, the worker namespace and the
allowlist, so the bare name resolved to nothing and the ban bought only a
message; a test asserts all three absences.latex.eval()is the
demonstrated danger and stays refused, as an attribute.getattrstays
fully forbidden — it really is in the builtins, because Sage needs it..system()is a method again (26) — the system of ODEs of a geodesic.
It was forbidden foros.system, andoscannot be spelled at all.
-
SageMath's own doctests, run against the validator.
tests/test_sage_doctest_corpus.pyharvests everysage:example in the
installed SageMath — 432,878 of them across 3,168 sources — and pushes each
throughpreparse+validate_module, grouped by docstring so names bound
early in a block authorise reads later, as a session does. It answers at scale
the question a hand-written table can only sample: would this server refuse
the mathematics SageMath itself documents? Against 10.9, in 48 seconds:
97.81% of in-scope examples accepted, every refusal attributable to a rule
that is named and capped in the file, and no allowlist gap in any mathematical
name. The corpus is SageMath's, GPL-2.0-or-later, read at run time and never
copied into this repository — only counts reach the assertions. -
scripts/analyse_doctest_refusals.py, which categorises the 8,218
refusals by whether the security justification holds: 35.8% deliberate and
sound, 29.4% not ours at all, and a third with no strong justification —
latex(...)at 1,387, the forbidden-global-shadows-a-local class at 783, the
REPL's_at 694, andoperator.leat 206. Recorded as items 45 and 46. -
Nine CLI cases in the new
numericsandphysicsdomains, chosen so the
memorable answer is the wrong one — π²/6 against a sum truncated at 10⁶,0.5
against a discretised oscillator, 43″ against Mercury's 42.98. Run with
--domain numerics,physics. -
The extended CLI runner distinguishes
DODGEDfromWRONG_ANSWER, using the
forbiddenmarkers each case already carried and nothing read. -
The runner reports what the server actually said.
TOOL_ERRORused to
print "the server returned isError for a tool call", so every diagnosis meant
re-running with the temporary wire log kept. It now quotes the message, which
turned three opaque Gemini failures into one word —import— on the first
read. -
A failed call whose mathematics Sage rejected no longer fails the case.
A divergent integral, a bracket with no sign change, an unevaluatedlimit()
thatN()cannot reduce: the model tries something else and answers
correctly, which is a session working. Refusals, dead workers and timeouts
stay fatal whatever happens afterwards — those are the defects this suite
exists to catch — and nothing passes without an accepted tool that succeeded
and the expected answer. -
The sdist shipped one file of documentation, not eight.
MANIFEST.in
listedUSAGE.md,TESTING.md,AGENTS.md,INSTALLATION.mdand everything
underdocs/, and had no effect: the build backend is hatchling, which does
not readMANIFEST.in. Every sdist containedREADME.mdalone while
DISTRIBUTION.mdandbuild_release.pyboth said documentation was included.
The file list moved to[tool.hatch.build.targets.sdist], where it works, and
MANIFEST.inis deleted rather than left looking authoritative. -
A research-workflow suite (
tests/test_research_workflows.py): nine
multi-step sessions at genuinely open problems — Collatz, Goldbach and its
weak form, twin primes and Legendre, odd perfect numbers, the Riemann
hypothesis, Birch–Swinnerton-Dyer, Erdős–Straus, sums of three cubes, and abc.
They test the session rather than the call: a helper defined in step one is
used in step five, which is both what distinguishes this server from a
stateless evaluator and the strongest stress on deny-by-default caller code,
since a mathematician writes loops, comprehensions and helper functions
freely. Assertions prefer invariants over remembered constants. 7 s in the
integration suite. -
Five open-problem CLI cases driving a real Claude/Gemini/Codex against the
server: twin-prime counts below 10^6, the Collatz record holder below 10^5,
the largest prime gap below 10^6, amicable pairs, and a curve's rank and
conductor. Every answer needs a real sweep, so a model that skips the server
cannot bluff past the wire-log check. -
A suite for mathematics that must work (
tests/test_math_coverage.py).
The security suite asserts things are blocked, so a policy that refused
everything would pass all of it. This covers the opposite failure, in six
layers: 34 binding forms, the same forms across calls, 72 mathematical truths
Sage itself evaluates, 19 groups of equivalent spellings that must agree, 17
preparser forms, and allowlist reachability by area with a size floor. Layers
that need no Sage run in the fast job, because that is where allowlist
regressions come from.
Fixed
nonlocalandglobalare permitted, the input limits admit a matrix, and
a withheld name says which spelling works. All three came out of reading the
doctest corpus's refusals as a work queue rather than an audit.nonlocalrebinds a name in an enclosing function, so it cannot reach the
worker namespace at all; it was refused by a policy flag with no comment, no
recorded rationale and no test named for it, and the cost was every closure
that accumulates something.globalfollowed a round later, once its own
question was answered: it binds at module scope, butSR = 5is permitted
at the top level anyway, so the declaration cannot be what makes it
dangerous. Verified against 10.9 —global unpickle_globalclaims a name
whose object was scrubbed and reads back aNameError, whilecython,
attrcallandsage_inputstay refused by name.max_source_charsrose from 8,000 to 131,072 andmax_ast_nodesfrom 2,500
to 50,000. A 40×40 integer matrix is 17,706 characters and 6,497 nodes
after preparsing — Sage wraps every literal asInteger(0)— so a matrix
small enough to paste was refused before anything looked at it, and raising
only the character limit would have left the node limit refusing a 25×25.
The new limits admit a 100×100 matrix and cap one request at roughly 140ms
of parsing, measured: preparse, parse and validate cost about 1.1µs per
character on 10.9, linearly. Execution is bounded separately by
eval_timeout.A.inject_variables()works, and the names outlive the call.
R.<u, v> = QQ[]was fine because the preparser binds statically, while
R = PolynomialRing(QQ, 'u,v')followed byR.inject_variables()— the
same mathematics written the other way — was refused, 741 times across the
corpus. A snippet that asks for an injection now has the allowlist half of
deny-by-default suspended, and only that half: the withheld check still
refuses every name that is live and not offered, so what suspending buys is
names that are not live at all, which are either the injected ones or a
NameError. The worker records what appeared so the next call can read it,
gated on the caller having written the call.inject_shorthandsis
deliberately excluded — Sage routes it through the REPL's globals, so
nothing lands here and the undeclared-symbol message is the better answer.- The length limit is checked before the preparser and after it. Raising
max_source_charsmade a new failure reachable: Sage rewrites every literal,
so 92 KB of arithmetic arrives at the parser as 506 KB, and the worker parses
before it validates. CPython got there first and answeredRecursionError: maximum recursion depth exceeded during ast construction— telling a caller
their mathematics broke the interpreter when it had exceeded a documented
limit. The check now runs on what the caller wrote and on what the
preparser produced, and the message says which: a number measured against
the typed source is the one the caller can act on. - A name withheld because it spawns an external program now names the
in-process equivalent:gap(...)points atSymmetricGroup(5)and
libgap,singular(...)atideal(...).groebner_basis(),
attrcall('bruhat_le')at the lambda it stands for. ~2,300 of the corpus's
refusals are these names, and
test_the_blocked_interfaces_do_not_block_the_mathematicsalready proved
each equivalent works — this writes down what that test knows. A loner
keeps the undeclared-symbol message, which is right: it is a radius far more
often than the R interface.
f(x) = x^2 + 1was refused. Sage's function-definition syntax — the
first thing in its tutorial, and how a physicist writesV(r) = -1/r—
expands to__tmp__=var("x"); f = symbolic_expression(...).function(x), and
the server validates the preparsed source, so the blanket dunder ban caught
the preparser's own scratch name.__tmp__is now permitted as an assignment
target only: the preparser never reads it back, a store cannot leak anything
the caller did not already hold, and every other dunder stays refused in both
directions (__builtins__ = {...}is a store). Found by writing the first
physics session, not by a security review — an over-block passes every test in
a suite that only asserts refusals.find_rootaccepts an equation. Kepler's equation arrives written as
E - 0.6*sin(E) = 0.75, every CLI passed it that way, andsage_eval
answeredinvalid syntax (<string>, line 1)— which names neither the cause
nor the fix, whilesolve_equationhad always accepted the form. The string
is split the same way, and only after the plain expression fails to parse, so
log(x, base=2) - 1is untouched.- The import refusal now says what to do instead. "Import statements are
disabled for Sage executions" is true and useless: Gemini opens numerical work
withimport numpy as np, was told only that imports are disabled, and failed
three physics cases in a row without recovering. The message now adds that
SageMath is already loaded and names what to reach for. The same three cases
were re-run against the same model: two now pass, and the third fails on a
function Gemini invented (bessel_Jn_zeros, which is SciPy's name), where the
refusal is correct. matchstatements andfunction('f')created unusable variables. The
allowlist trusts names the caller's own code binds, and binding was detected
fromNamenodes alone:matchpatterns bind through their own node types,
and Sage'sfunction('f')injects a name exactly asvar()does. Every
variable in a match statement, and every barefunction()declaration, read
as undefined for the rest of the session.- Uniformly indented code is no longer refused. A snippet lifted out of a
markdown block arrives with four spaces on every line, and was rejected for
its margin rather than its mathematics. Caller code is dedented before
validation and before execution; a valid program cannot be changed by it,
since valid module-level code has no common indent to remove. - A refusal now names a fix the caller can perform. SageMath predefines only
x, sodiff(x^2*y^3, x, y)needsvar('y')— but the allowlist answered
that the name "needs to be added to the allowlist", which is true and useless
to a model that will simply retry. Short lowercase names are now told to
declare the symbol. evaluate_sage_streaminghad no error handling at all: a timeout, a security
violation or a dead worker propagated raw and none were recorded, while
evaluate_sagereported each properly.- A failed journal write destroyed the previous journal. The superseded file was
deleted before the new one was written, so a full disk left the session with no
journal and a stray temporary file. The new file is put in place first now. - A timed-out evaluation propagated as a bare
TimeoutError: monitoring recorded
nothing and the client saw an unstructured error. Bothevaluate_sageand the
specialised tools now report it as a tool error carrying the deadline. - Three examples in the
evaluate_sagedescription were wrong under any
execution model: the Laplace pair needed its symbols declared (Sage's REPL
predefines onlyx),desolve_rsolvedoes not exist, and
continued_fractionhas nontermskeyword.
Removed
docs/reference_md/andEVALUATION.md. The reference directory was
orphaned: no code read it, and theresource://sagemath/docs/{scope}resource
serves links into the upstream manual directly, so the local copy was 200-odd
links pointing at the same URLs, and already accruing maintenance (one page
recommendedsearch_src, which callers may no longer use).scripts/convert_html_to_md.py, which only regenerated it, goes too.
EVALUATION.mdwas an April snapshot whose verdict was superseded, whose
security assessment had become wrong, and whose entire backlog had shipped.
Documentation
- A documentation pass over every markdown file, checking each claim against
the code rather than re-reading the prose. What it found:USAGE.mdlisted 33
of 37 tools while its header said 37 — the four missing ones were
interrupt_sage_session, which the same page recommends in prose, and the
three that make up named workspaces;INSTALLATION.mdcalled the SageMath
runtime "optional" when without it every evaluation fails withUnable to locate Sage executable; the README architecture diagram
advertised response caching that was deliberately turned off as an isolation
bug. Test counts, the predefined symbols and the security framing were stale
in several places. - A test now enforces that every tool is documented in
USAGE.mdand
README.md, because that table drifted by four without anything failing. - Security documentation gained the design principle behind the
operator
finding: every attribute rule is enforced on the source text, so any primitive
that fetches an attribute by a runtime string defeats all of them at once.