Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ $ phil --format pretty 'Matrix([[1,2],[3,4]])'
| Solve linear system (Ax=b) | `msolve(Matrix([[...]]), Matrix([...]))` or `linalg solve A=[[...]] b=[...]` |
| Symbolic linear solve | `linsolve((Eq(...), Eq(...)), (x, y))` |

Notes:
For Ax=b, use `linalg solve A=[[...]] b=[...]` or `msolve(A, b)` instead of `solve(A=..., b=...)`.

### Common symbols

`x`, `y`, `z`, `t`, `pi`, `e`, `f`
Expand Down
67 changes: 67 additions & 0 deletions REDTEAM_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Redteam Security and Architecture Report: `phil` (SymPy CLI Calculator)

**Target Repository:** `sacchen/phil` (Local path: `/Users/goddess/foundry/sandbox/calc`)
**Focus Areas:** Accuracy, Relevance, Style, and Security Posture
**Date:** April 11, 2026

## Executive Summary

`phil` is a sophisticated command-line interface (CLI) calculator that leverages the power of Python's `SymPy` library to provide Computer Algebra System (CAS) capabilities directly in the terminal. This redteam analysis evaluates the application's resilience against common vulnerabilities associated with symbolic computation and mathematical parsing—specifically Arbitrary Code Execution (ACE) and Denial of Service (DoS) via resource exhaustion.

The codebase demonstrates an exceptionally high level of maturity. The developers have anticipated the core vulnerabilities of `eval`-based symbolic evaluation and implemented rigorous, Abstract Syntax Tree (AST)-level defenses. While highly secure and well-architected, its primary weaknesses lie in the inherent mathematical ambiguities of its "relaxed" parsing mode and the operational security (OpSec) implications of its fallback mechanisms.

---

## 1. Accuracy & Functional Security

Mathematical parsers in Python that wrap `SymPy` are typically fraught with two major vulnerabilities: **Arbitrary Code Execution (ACE)** and **Denial of Service (DoS)**. `phil` implements robust defenses against both.

### A. Arbitrary Code Execution (ACE) Mitigation
* **The Threat:** SymPy’s `parse_expr` function internally utilizes Python's `eval()`. Without strict sanitization, a malicious user could input a string like `__import__('os').system('rm -rf /')`, leading to catastrophic system compromise.
* **The Defense:** The application successfully neutralizes this threat through a multi-layered defense-in-depth approach:
1. **Lexical Scrubbing:** It scrubs dangerous characters via `BLOCKED_PATTERN = re.compile(r"(__|;|\n|\r)")`, breaking most introspection attempts before they reach the parser.
2. **Namespace Isolation (The Gold Standard):** Instead of relying solely on string sanitization (which is prone to bypasses), `phil` enforces a strict namespace. It calls `parse_expr` with `global_dict=GLOBAL_DICT`, explicitly overriding Python's `__builtins__` with an empty dictionary. It exclusively whitelists safe, mathematical operations (e.g., `Add`, `Mul`, `Integer`, `Float`).
* **Verdict:** **Highly Secure.** The team has properly sandboxed the evaluation environment, making code injection virtually impossible through standard CLI usage.

### B. Resource Exhaustion / DoS (Advanced Mitigation)
* **The Threat:** Symbolic engines will happily consume unbounded memory or CPU time if asked to calculate massive numbers (e.g., `10**10**10`) or massive factorials (e.g., `10000000!`). This causes the application to hang indefinitely.
* **The Defense:** Instead of relying on crude and often unreliable multi-threading timeouts, the author implements brilliant AST-level inspections *before* mathematical evaluation:
* **Pre-Evaluation Scanning:** Functions like `_validate_factorial_literals` and `_raise_if_huge_factorial_call` scan the pre-evaluated expression tree (created safely via `evaluate=False`) for factorial arguments exceeding a safe `MAX_FACTORIAL_N`.
* **Algebraic Cancellation Check (`_reduce_huge_integer_powers`):** This is a standout feature. It parses the expression safely, finds huge integer powers, and substitutes them with `Dummy` symbols. It then runs SymPy's `simplify()`. If the huge power algebraically cancels out (e.g., `(10**10**10) / (10**10**10)`), it successfully returns `1`. If the dummy symbol remains after simplification, it halts and throws a `ValueError` rather than attempting to materialize the massive integer in memory.
* **Verdict:** **Highly Accurate & Resilient.** The edge-case handling for structural mathematics is exceptionally robust and well-engineered.

### C. Implicit Multiplication Ambiguity (The Primary Weakness)
* **The Threat:** `phil` heavily advertises "relaxed parsing," leveraging SymPy’s `implicit_multiplication_application` transformations to allow inputs like `2x` instead of `2*x`.
* **The Problem:** The ambiguity of expressions like `x y` (is it a single variable `xy` or `x * y`?) is a computationally unsolvable problem without strict user intent. While convenient, the parser's heuristics occasionally guess wrong, which can silently alter the user's intended mathematical equation, leading to incorrect results without throwing an error.
* **Verdict:** **Moderate Accuracy Risk.** While the "strict" mode circumvents this, the default "relaxed" mode sacrifices mathematical exactness for user convenience—a classic "foot-gun" in production calculator usage.

---

## 2. Relevance and Operational Security (OpSec)

* **The Target Audience:** The tool perfectly bridges the gap between lightweight calculators (like `bc` or `qalc`) and heavy computational environments (like Jupyter notebooks). It brings rich formatting (LaTeX, pretty-printing) to the terminal.
* **The "WolframAlpha Fallback" (Privacy Leak):** When the local SymPy engine fails to parse or simplify an expression, the CLI provides a formatted fallback URL to WolframAlpha.
* *Critique:* While highly relevant for User Experience (UX) and "failing gracefully", this mechanism introduces a significant privacy and OpSec risk. Users might implicitly trust the offline, local nature of a terminal CLI. Clicking the fallback URL transmits their exact equation structure (which could contain proprietary algorithms, financial figures, or sensitive data) to a third-party commercial server.
* **Dependency Bloat:** Requiring a full Python environment and the massive `SymPy` library just to do quick terminal math makes it somewhat heavy for users who only need simple arithmetic.

---

## 3. Style & Maintainability

* **Test Suite Rigor:** The project boasts an immaculate and comprehensive `pytest` suite. Local test runs indicate 282 passing tests heavily utilizing hypothesis/regression structures. Critically, the tests explicitly verify the safety boundaries (e.g., `test_cli_huge_factorial_fails_fast_with_hint`, `test_cli_safety_guards_blocked_and_too_long_input`), proving the security guardrails are maintained features, not happy accidents.
* **Architecture & Separation of Concerns:** The codebase elegantly separates CLI logic (`cli.py`), mathematical routing (`core.py`, `linalg.py`, `ode.py`), and presentation (`render.py`). This strict separation allows the underlying CAS to be swapped out or upgraded without breaking the terminal UI.
* **Code Quality:** The Python code is thoroughly type-hinted and relies on modern idioms. Error handling is proactive, intercepting generic Python/SymPy crashes and translating them into user-friendly diagnostics with actionable recovery hints.

---

## 4. Key Takeaways

### For Users:
1. **Trust the Sandbox:** The tool is highly secure against code injection. You can safely run it locally without fear of accidental system execution.
2. **Beware Relaxed Parsing:** If performing critical engineering or financial calculations, explicitly use `*` for multiplication or run the tool in strict mode to avoid silent parsing errors.
3. **Mind Your OpSec:** Be cautious when using the WolframAlpha fallback links if your equations contain sensitive or proprietary data, as this transmits your input over the internet.

### For Developers/Maintainers:
1. **Exemplary Defensive Programming:** The use of AST pre-parsing (`evaluate=False`) combined with Dummy symbol cancellation checks is a masterclass in preventing DoS in symbolic computation. This pattern should be documented and celebrated.
2. **Consider an Opt-in Telemetry/Network Model:** The WolframAlpha link generation should ideally come with a first-time warning or require an explicit flag to ensure users are aware their local CLI session might bridge to the web.
3. **Dependency Alternatives:** While SymPy is powerful, exploring lighter-weight parsing libraries or compiled extensions (like Rust bindings) in the future could drastically reduce the installation footprint.
18 changes: 13 additions & 5 deletions src/calc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def _calc_version() -> str:
f"{CLI_NAME} v{VERSION} - symbolic CLI calculator\n"
"\n"
"usage:\n"
f" {CLI_NAME} [--format MODE] [--latex|--latex-inline|--latex-block] [--strict] [--no-simplify] [--explain-parse] [--wa] [--copy-wa] [--color MODE] '<expression>'\n"
f" {CLI_NAME} [--format MODE] [--latex|--latex-inline|--latex-block] [--strict] [--no-simplify] [--explain-parse] [--wa] [--copy-wa] [--no-wa] [--color MODE] '<expression>'\n"
f" {CLI_NAME}\n"
f" {CLI_NAME} :examples\n"
"\n"
Expand All @@ -77,6 +77,7 @@ def _calc_version() -> str:
" --explain-parse show normalized expression on stderr\n"
" --wa always print WolframAlpha equivalent link\n"
" --copy-wa copy WolframAlpha link to clipboard when shown\n"
" --no-wa never print WolframAlpha equivalent link\n"
" --color MODE diagnostics color: auto, always, never\n"
"\n"
"upgrade:\n"
Expand Down Expand Up @@ -312,12 +313,13 @@ def _print_error(
expr: str | None = None,
color_mode: str = "auto",
session_locals: dict | None = None,
no_wa: bool = False,
) -> None:
print(_style(f"E: {exc}", color="red", stream=sys.stderr, color_mode=color_mode), file=sys.stderr)
hint = _hint_for_error(str(exc), expr=expr, session_locals=session_locals)
if hint:
print(_style(f"hint: {hint}", color="yellow", stream=sys.stderr, color_mode=color_mode), file=sys.stderr)
if expr and should_print_wolfram_hint(exc):
if not no_wa and expr and should_print_wolfram_hint(exc):
_print_wolfram_hint(expr, color_mode=color_mode)


Expand Down Expand Up @@ -543,6 +545,7 @@ def _execute_expression(
explain_parse: bool,
always_wa: bool,
copy_wa: bool,
no_wa: bool,
color_mode: str,
session_locals: dict | None = None,
) -> None:
Expand Down Expand Up @@ -599,7 +602,7 @@ def _execute_expression(
_style("hint: zoo = complex infinity; the expression is undefined (e.g. division by zero)", color="yellow", stream=sys.stderr, color_mode=color_mode),
file=sys.stderr,
)
if always_wa or _is_complex_expression(expr):
if not no_wa and (always_wa or _is_complex_expression(expr)):
_print_wolfram_hint(expr, copy_link=copy_wa, color_mode=color_mode)


Expand All @@ -620,6 +623,7 @@ def run(argv: list[str] | None = None) -> int:
explain_parse = options.explain_parse
always_wa = options.always_wa
copy_wa = options.copy_wa
no_wa = options.no_wa
color_mode = options.color_mode
remaining = list(options.remaining)

Expand All @@ -636,11 +640,12 @@ def run(argv: list[str] | None = None) -> int:
explain_parse=explain_parse,
always_wa=always_wa,
copy_wa=copy_wa,
no_wa=no_wa,
color_mode=color_mode,
)
return 0
except Exception as exc:
_print_error(exc, expr, color_mode=color_mode)
_print_error(exc, expr, color_mode=color_mode, no_wa=no_wa)
return 1

startup_update_lines = _repl_startup_update_status_lines()
Expand All @@ -661,6 +666,7 @@ def run(argv: list[str] | None = None) -> int:
repl_explain_parse = explain_parse
repl_always_wa = always_wa
repl_copy_wa = copy_wa
repl_no_wa = no_wa
repl_color_mode = color_mode
tutorial_state = {"active": False, "index": 0}
expr: str | None = None
Expand All @@ -685,6 +691,7 @@ def run(argv: list[str] | None = None) -> int:
repl_explain_parse = parsed_inline.explain_parse
repl_always_wa = parsed_inline.always_wa
repl_copy_wa = parsed_inline.copy_wa
repl_no_wa = parsed_inline.no_wa
repl_color_mode = parsed_inline.color_mode
if not parsed_inline.remaining:
print("hint: REPL options updated for this session", file=sys.stderr)
Expand All @@ -698,6 +705,7 @@ def run(argv: list[str] | None = None) -> int:
explain_parse=repl_explain_parse,
always_wa=repl_always_wa,
copy_wa=repl_copy_wa,
no_wa=repl_no_wa,
color_mode=repl_color_mode,
session_locals=session_locals,
)
Expand All @@ -706,7 +714,7 @@ def run(argv: list[str] | None = None) -> int:
return 0
except Exception as exc:

_print_error(exc, expr=expr, color_mode=repl_color_mode, session_locals=session_locals)
_print_error(exc, expr=expr, color_mode=repl_color_mode, session_locals=session_locals, no_wa=repl_no_wa)



Expand Down
53 changes: 53 additions & 0 deletions src/calc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,55 @@ def _validate_expression(expression: str) -> None:
raise ValueError("blocked token in expression")


def _split_top_level_commas(text: str) -> list[str]:
parts: list[str] = []
current: list[str] = []
paren_depth = 0
bracket_depth = 0
brace_depth = 0
for ch in text:
if ch == "(":
paren_depth += 1
elif ch == ")":
paren_depth = max(0, paren_depth - 1)
elif ch == "[":
bracket_depth += 1
elif ch == "]":
bracket_depth = max(0, bracket_depth - 1)
elif ch == "{":
brace_depth += 1
elif ch == "}":
brace_depth = max(0, brace_depth - 1)
if ch == "," and paren_depth == 0 and bracket_depth == 0 and brace_depth == 0:
piece = "".join(current).strip()
if piece:
parts.append(piece)
current = []
continue
current.append(ch)
tail = "".join(current).strip()
if tail:
parts.append(tail)
return parts


def _is_matrix_style_solve_input(expression: str) -> bool:
stripped = expression.strip()
match = re.match(r"solve\s*\((.*)\)\s*$", stripped)
if not match:
return False
args = _split_top_level_commas(match.group(1))
top_level_kwargs: set[str] = set()
for arg in args:
if "=" not in arg:
continue
name, _value = arg.split("=", 1)
name = name.strip()
if name.isidentifier():
top_level_kwargs.add(name)
return "A" in top_level_kwargs and ("b" in top_level_kwargs or "B" in top_level_kwargs)


def normalize_expression(expression: str, relaxed: bool = False) -> str:
normalized = _strip_outer_wrappers(expression)
normalized = normalized.replace("−", "-")
Expand Down Expand Up @@ -552,6 +601,8 @@ def evaluate(
):
_validate_expression(expression)
normalized = normalize_expression(expression, relaxed=relaxed)
if _is_matrix_style_solve_input(expression) or _is_matrix_style_solve_input(normalized):
raise ValueError("solve() does not accept matrix keyword arguments")
transforms = RELAXED_TRANSFORMS if relaxed else TRANSFORMS
local_dict = dict(LOCALS_DICT)
if session_locals:
Expand All @@ -562,6 +613,8 @@ def evaluate(
name, rhs = match.group(1), match.group(2)
if name in LOCALS_DICT:
raise ValueError(f"cannot assign reserved name: {name}")
if _is_matrix_style_solve_input(rhs):
raise ValueError("solve() does not accept matrix keyword arguments")
parsed_rhs = _parse_with_guardrails(
rhs,
local_dict=local_dict,
Expand Down
4 changes: 4 additions & 0 deletions src/calc/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ def hint_for_error(message: str, expr: str | None = None, session_locals: dict |
if "missing 1 required positional argument" in text or "positional argument" in text:
return "den syntax: den(expr) (for example den(3/14))"

if compact_expr.startswith("solve(") or re.match(r"^[a-z][a-z0-9_]*=solve\(", compact_expr):
if "matrix keyword arguments" in text or "unexpected keyword argument" in text:
return "matrix solve syntax: linalg solve A=[[...]] b=[...] or msolve(A, b)"

if text.startswith("linalg ") or text.startswith("unknown linalg "):
return "linalg syntax: 'linalg solve A=[[...]] b=[...]', 'linalg rref A=[[...]]', 'linalg det/inv/rank/eig/nullspace A=[[...]]'; use :linalg"
if "unexpected eof" in text:
Expand Down
7 changes: 7 additions & 0 deletions src/calc/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class CLIOptions:
explain_parse: bool = False
always_wa: bool = False
copy_wa: bool = False
no_wa: bool = False
color_mode: str = "auto"
remaining: tuple[str, ...] = ()

Expand All @@ -25,6 +26,7 @@ def parse_options(args: list[str], *, help_text: str) -> CLIOptions:
explain_parse = False
always_wa = False
copy_wa = False
no_wa = False
color_mode = "auto"
idx = 0
while idx < len(args) and args[idx].startswith("-"):
Expand Down Expand Up @@ -80,6 +82,10 @@ def parse_options(args: list[str], *, help_text: str) -> CLIOptions:
copy_wa = True
idx += 1
continue
if arg == "--no-wa":
no_wa = True
idx += 1
continue
if arg == "--color":
if idx + 1 >= len(args):
raise ValueError("missing value for --color")
Expand Down Expand Up @@ -110,6 +116,7 @@ def parse_options(args: list[str], *, help_text: str) -> CLIOptions:
explain_parse=explain_parse,
always_wa=always_wa,
copy_wa=copy_wa,
no_wa=no_wa,
color_mode=color_mode,
remaining=tuple(args[idx:]),
)
22 changes: 22 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,28 @@ def test_cli_force_wolfram_hint():
assert "hint: try WolframAlpha:" in proc.stderr


def test_cli_no_wa_suppresses_wolfram_hint():
proc = run_cli("--no-wa", "bad(")
assert proc.returncode == 1
assert "hint: try WolframAlpha:" not in proc.stderr

proc = run_cli("--wa", "--no-wa", "2+2")
assert proc.returncode == 0
assert "hint: try WolframAlpha:" not in proc.stderr


def test_repl_no_wa_suppresses_wolfram_hint():
proc = subprocess.run(
[sys.executable, "-m", "calc"],
input="--no-wa\nbad(\n:q\n",
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0
assert "hint: try WolframAlpha:" not in proc.stderr


def test_cli_version_shortcut():
proc = run_cli(":version")
assert proc.returncode == 0
Expand Down
Loading
Loading