#5070·PraisonAI

Wrapper gaps: recipe rate-limiter bypassable+worker-unsafe, multiedit corrupts files on crash, __main__ Typer/legacy split billed to LLM

Author: MervinPraisonCreated Sep 14, 2026Updated Sep 14, 2026
Labelsbugsecurityclaude

Scope

In-depth review of src/praisonai/praisonai/ only. Ranked by production-correctness impact and how well they map to the stated philosophy pillars (production-ready, multi-agent + async safe by default, simpler). All findings verified against current source on main.

Deliberately not duplicating already-reported wrapper gaps (#4594 db/persistence/security/AgentsGenerator, #2191 command-registry drift). What follows are three new, concrete, actionable gaps with the smallest fix that closes each.


Gap 1: recipe/serve.py rate limiter is bypassable, worker-unsafe, and its multi-worker deployment silently degrades to 1 worker

The wrapper documents the recipe HTTP server as production-grade (auth, rate limit, metrics, workers, tracing). Three independent defects in one small area turn each of those from a real control into a nominal one.

Verified locations

  • src/praisonai/praisonai/recipe/serve.py:90-123RateLimiter state is a plain defaultdict(list) with no lock around read/append.
  • src/praisonai/praisonai/recipe/serve.py:779-781 — bucket id is request.headers.get("X-API-Key") or request.client.host, i.e. client-controlled and used unconditionally, even when the auth mode is none or jwt (where X-API-Key is never validated).
  • src/praisonai/praisonai/recipe/serve.py:830-833 — rate-limit middleware is registered globally with no per-worker sharing hook.
  • src/praisonai/praisonai/recipe/serve.py:836-873serve() accepts workers: int = 1 and passes the live app object to uvicorn.run(app, ..., workers=workers if workers > 1 else None); uvicorn requires an import string when workers > 1, so this either raises or silently reruns as a single worker (depending on version), and there is no way for the user to actually run multiple workers with this API.

Current code

python
# recipe/serve.py:90-123 — no lock; concurrent async requests race on _requests
class RateLimiter:
    def __init__(self, requests_per_minute: int = DEFAULT_RATE_LIMIT):
        self.requests_per_minute = requests_per_minute
        self.window_seconds = 60
        self._requests: Dict[str, List[float]] = defaultdict(list)

    def check(self, client_id: str) -> Tuple[bool, int]:
        ...
        self._requests[client_id] = [
            t for t in self._requests[client_id] if t > window_start
        ]
        if len(self._requests[client_id]) >= self.requests_per_minute:
            ...
        self._requests[client_id].append(now)
        return True, 0
python
# recipe/serve.py:779 — bucket id is untrusted client header
class RateLimitMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        if rate_limiter is None:
            return await call_next(request)
        if request.url.path in rate_limit_exempt:
            return await call_next(request)

        # Attacker rotates X-API-Key each request → each value gets its own bucket
        # → 100 rpm limit becomes effectively unlimited.
        client_id = request.headers.get("X-API-Key") or request.client.host if request.client else "unknown"

        allowed, retry_after = rate_limiter.check(client_id)
        ...
python
# recipe/serve.py:836-873 — workers>1 silently doesn't work
def serve(host="127.0.0.1", port=8765, reload=False, config=None, workers: int = 1):
    ...
    app = create_app(config)
    if workers > 1 and reload:
        warnings.warn("Cannot use reload with multiple workers. Disabling reload.")
        reload = False
    # `workers > 1` requires an import string, not an app object.
    uvicorn.run(app, host=host, port=port, reload=reload, workers=workers if workers > 1 else None)

Why it matters

  • Bypass: an unauthenticated client (auth none) or a JWT-holder (auth jwt) rotates X-API-Key each request and defeats rate-limiting entirely. X-API-Key is only validated when the server's auth mode is exactly api-key; the rate limiter uses it as a bucket regardless of mode.
  • Not-really-multi-worker: workers > 1 is a stated feature of serve() but the app-object form disables it. Even if it worked, RateLimiter._requests lives inside the worker process, so a 100 rpm limit multiplied by N workers is really 100·N per client from the caller's perspective.
  • Async race: defaultdict[client_id] list is read, filtered, length-checked and appended without a lock. Two overlapping asyncio tasks on the same client can both pass the length check before either appends, so a small burst can exceed the configured limit even in a single worker.

Violates the production-ready pillar ("safe by default") and the wrapper's own docstring promise of a rate-limit control.

Proposed fix

python
# recipe/serve.py — thread/async-safe limiter with pluggable backend
import asyncio
from typing import Protocol

class RateLimiterBackend(Protocol):
    async def check(self, client_id: str) -> Tuple[bool, int]: ...

class InMemoryRateLimiter(RateLimiterBackend):
    def __init__(self, requests_per_minute: int):
        self.requests_per_minute = requests_per_minute
        self.window_seconds = 60
        self._requests: Dict[str, List[float]] = defaultdict(list)
        self._lock = asyncio.Lock()   # <-- fixes the async race

    async def check(self, client_id: str) -> Tuple[bool, int]:
        if self.requests_per_minute <= 0:
            return True, 0
        now = time.time()
        window_start = now - self.window_seconds
        async with self._lock:
            bucket = [t for t in self._requests[client_id] if t > window_start]
            self._requests[client_id] = bucket
            if len(bucket) >= self.requests_per_minute:
                retry_after = int(min(bucket) + self.window_seconds - now) + 1
                return False, max(1, retry_after)
            bucket.append(now)
            return True, 0

# Optional: RedisRateLimiter(RateLimiterBackend) for workers > 1 — a shared
# atomic INCR + EXPIRE keyed by client_id is ~15 lines and closes the multi-
# worker gap without any new hard dependency (redis is already used elsewhere
# in the wrapper).
python
# recipe/serve.py — resolve bucket id after auth, from the validated identity
async def dispatch(self, request, call_next):
    if rate_limiter is None or request.url.path in rate_limit_exempt:
        return await call_next(request)

    # Precedence: validated JWT sub → validated API key → client IP → "anonymous".
    # Only a value the auth middleware has already validated ends up in client_id.
    user = getattr(request.state, "user", None)          # set by JWTAuthMiddleware
    if user and "sub" in user:
        client_id = f"jwt:{user['sub']}"
    elif getattr(request.state, "authenticated_api_key", None):
        client_id = f"apikey:{request.state.authenticated_api_key}"
    elif request.client:
        client_id = f"ip:{request.client.host}"
    else:
        client_id = "anonymous"

    allowed, retry_after = await rate_limiter.check(client_id)
    if not allowed:
        return JSONResponse(
            {"error": {"code": "rate_limited", "message": "Too many requests"}},
            status_code=429, headers={"Retry-After": str(retry_after)},
        )
    return await call_next(request)
python
# recipe/serve.py — honour workers>1 by handing uvicorn an import string
def serve(host="127.0.0.1", port=8765, reload=False, config=None, workers: int = 1):
    if workers > 1:
        # Uvicorn requires an import string for reload/workers. Expose an
        # app-factory module so the config still travels through the environment.
        os.environ["PRAISONAI_RECIPE_SERVE_CONFIG"] = json.dumps(config or {})
        uvicorn.run(
            "praisonai.recipe._serve_factory:app",   # calls create_app() from env
            host=host, port=port, reload=False, workers=workers,
            factory=False,
        )
        return
    uvicorn.run(create_app(config), host=host, port=port, reload=reload)

Result: rate-limiting can no longer be defeated by rotating an unvalidated header, the async race is closed, and workers>1 actually gives you N workers (with the Redis backend for a real cross-worker limit).


Gap 2: multiedit tool writes files non-atomically — a crash mid-write leaves the file truncated / partially written

The wrapper already implements the correct pattern in auto.py:_atomic_write_text (tempfile → fsync → os.replace) and uses it for YAML writes. multiedit, the tool exposed to agents — the code path most likely to be interrupted by cancellation, timeout, or a killed subprocess — uses raw open('w').

Verified locations

  • src/praisonai/praisonai/tools/multiedit.py:104-166 — reads whole file, mutates a Python string in memory, writes back with open(filepath, 'w').
  • src/praisonai/praisonai/auto.py:70-105 — the correct atomic-write helper already lives in the wrapper, unused by multiedit.

Current code

python
# tools/multiedit.py:104-166
try:
    with open(filepath, 'r') as f:
        original_content = f.read()

    content = original_content
    lines = content.split('\n')

    # Apply edits (find+replace, possibly with fuzzy match) ...

    # Write file if not dry run
    if not dry_run and result["edits_applied"] > 0:
        with open(filepath, 'w') as f:      # <-- truncates immediately;
            f.write(content)                #     interrupt here = corrupt file
except Exception as e:
    result["error"] = str(e)
python
# auto.py:70-105 — the correct pattern, already in the wrapper, not reused here
def _atomic_write_text(path, content_writer):
    target_dir = os.path.dirname(os.path.abspath(path)) or "."
    fd, tmp_path = tempfile.mkstemp(prefix=".tmp_", suffix=".yaml", dir=target_dir)
    try:
        # preserve existing mode so a replaced config stays readable
        try:
            existing_mode = os.stat(path).st_mode
        except OSError:
            existing_mode = None
        with os.fdopen(fd, "w") as f:
            content_writer(f)
            f.flush()
            os.fsync(f.fileno())            # durability before rename
        if existing_mode is not None:
            try:
                os.chmod(tmp_path, existing_mode)
            except OSError:
                pass
        os.replace(tmp_path, path)          # atomic on POSIX + Windows
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise

Why it matters

  • multiedit is exposed to agents, i.e. to LLM-driven tool calls that regularly run under timeouts, cancellations, and subprocess kills. open(filepath, 'w') truncates the target immediately, so any interrupt between open and write — cancellation, SIGTERM, disk-full — leaves the file zero-byte or partially written. The original content is gone.
  • No fsync: even a completed write is not durable on power loss; the file may come back empty after a crash.
  • No file lock: two concurrent multiedit calls on the same file interleave freely (agent A reads at t=0, agent B reads+writes at t=1, agent A writes at t=2, silently discarding agent B's edit — the classic lost-update).
  • The wrapper already ships the right pattern (_atomic_write_text in auto.py). The DRY principle in the philosophy is directly violated: the safe pattern exists and just isn't reused.

Violates production-ready ("safe by default") and **multi-agent safe by default".

Proposed fix

Promote _atomic_write_text to a shared helper (or duplicate the 20-line pattern into multiedit) and take a POSIX lock while the edits are applied so a concurrent editor sees a consistent snapshot:

python
# praisonai/_io.py  (new — one owner for atomic writes across the wrapper)
import os, tempfile
from typing import Callable

def atomic_write_text(path: str, write: Callable[[object], None]) -> None:
    target_dir = os.path.dirname(os.path.abspath(path)) or "."
    fd, tmp_path = tempfile.mkstemp(prefix=".tmp_", dir=target_dir)
    try:
        try:
            existing_mode = os.stat(path).st_mode
        except OSError:
            existing_mode = None
        with os.fdopen(fd, "w") as f:
            write(f)
            f.flush()
            os.fsync(f.fileno())
        if existing_mode is not None:
            try:
                os.chmod(tmp_path, existing_mode)
            except OSError:
                pass
        os.replace(tmp_path, path)
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
python
# tools/multiedit.py — use the atomic writer + a lockfile for the RMW window
import contextlib, fcntl
from praisonai._io import atomic_write_text

@contextlib.contextmanager
def _file_lock(path: str):
    lock_path = path + ".lock"
    fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
    try:
        fcntl.flock(fd, fcntl.LOCK_EX)      # advisory; blocks concurrent editors
        yield
    finally:
        fcntl.flock(fd, fcntl.LOCK_UN)
        os.close(fd)

# in multiedit(...) — wrap the read-modify-write in the lock and swap the write
with _file_lock(filepath):
    with open(filepath, "r") as f:
        original_content = f.read()

    content = _apply_all_edits(original_content, edits, result)   # unchanged logic

    if not dry_run and result["edits_applied"] > 0:
        atomic_write_text(filepath, lambda f: f.write(content))

Result: multiedit becomes crash-safe (no zero-byte truncation), durable (fsync), and free of the lost-update race between concurrent agents — with roughly 20 net lines of code and one shared helper the rest of the wrapper can also depend on.


Gap 3: __main__.py still runs two full CLI dispatchers side-by-side — 774-line routing heuristics, short-flag collisions, and typed-verb regressions that get billed to an LLM

Even after #2191 unified the two command registries, the wrapper still ships two fully separate CLI engines: a modern Typer app (cli/app.py + cli/commands/*.py) and a legacy argparse dispatcher (cli/legacy/dispatch/argparse_builder.py + cli/legacy/subcommand_handlers.py). Every invocation is classified by 774 lines of routing heuristics in __main__.py — including a fuzzy-typo matcher and a _LEGACY_VERBS_FALLBACK static mirror kept as a "fail-closed" hedge against verb-discovery drift. When the heuristics misfire the cost is not cosmetic: an implemented legacy verb falls through the classifier and is quoted as a prompt and billed to an LLM (this is #4327 — the bug the fallback mirror exists to prevent).

Verified locations

  • src/praisonai/praisonai/__main__.py:1-774 — the whole file is dispatcher routing between the two engines.
  • src/praisonai/praisonai/__main__.py:42-55_LEGACY_VERBS_FALLBACK: a hard-coded 60-verb mirror of the legacy dispatcher's real verb set, deliberately kept up to date "by hand" so that if LEGACY_SPECIAL_COMMANDS import fails, verbs still don't get billed to an LLM.
  • src/praisonai/praisonai/__main__.py:79-85_LEGACY_COLLIDING_SHORT_OPTS = frozenset({"-s", "-f"}): same short flag means different things in each engine (-s = legacy --save bool vs. modern --session value; -f = legacy --file vs. modern --framework).
  • src/praisonai/praisonai/__main__.py:210-265_mistyped_command_suggestions: a difflib heuristic that lives here because otherwise praisonai deploi (a typo) would be silently forwarded to the modern run engine and billed to an LLM as if the user meant it as a prompt.
  • src/praisonai/praisonai/__main__.py:600-638_run_typer vs _run_legacy: two separate engines each re-parse sys.argv.
  • src/praisonai/praisonai/cli/legacy/dispatch/argparse_builder.py (375 lines) and subcommand_handlers.py — the legacy engine that only exists because the modern engine hasn't absorbed a set of deprecated flags (--auto, --serve, etc.).

Current code

python
# __main__.py:42-55  — static mirror kept in sync by hand so a discovery
# failure doesn't reintroduce the LLM-billing regression from #4327
_LEGACY_VERBS_FALLBACK = frozenset({
    'chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research',
    'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools',
    'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile',
    ... (60 verbs)
})

# __main__.py:79-85 — same short flag has DIFFERENT meanings in the two engines
_LEGACY_COLLIDING_SHORT_OPTS = frozenset({"-s", "-f"})
# -s : legacy `--save` (bool) vs modern `--session` (takes a value)
# -f : legacy `--file` (input file) vs modern `--framework`

# __main__.py:210-265  — a difflib matcher exists here ONLY because a
# mistyped verb otherwise silently reaches the LLM as a prompt
def _mistyped_command_suggestions(argv, first_cmd):
    ...
    matches = difflib.get_close_matches(first_cmd, sorted(commands), n=3, cutoff=0.8)
    matches = [cmd for cmd in matches if not first_cmd.startswith(cmd)]
    return matches

# __main__.py:645-772 — main() is a hand-tuned decision tree over
# --version / --help / no-args / typer-command / legacy-verb / typo /
# bare-prompt / yaml-target / everything-else
def main():
    argv = sys.argv[1:]
    if "--version" in argv or "-V" in argv: ...
    if "--help" in argv or "-h" in argv: _run_typer(argv); return
    if not argv: _run_typer(argv); return
    ...
    if _is_implemented_legacy_verb(first_cmd): _run_legacy(argv); return
    suggestions = _mistyped_command_suggestions(argv, first_cmd)
    if suggestions: print(...); sys.exit(2)
    if _looks_like_bare_prompt(argv, first_cmd): _run_typer(_build_run_argv(...))
    elif _looks_like_yaml_run_target(argv, first_cmd): _run_typer([..., "run", *yaml_rest])
    else: _run_legacy(argv)

Why it matters

  • Cost of a misclassification is real money. #4327 wasn't a UX bug; a legacy verb (praisonai thinking status) was quoted and shipped to an LLM as a prompt. The _LEGACY_VERBS_FALLBACK static mirror and the mistyped-command heuristic exist because misclassification loses cash. Every new legacy verb has to be remembered in this hand-maintained fallback set.
  • Silent flag reinterpretation. -s and -f have opposite semantics in the two engines. Today the router treats their presence as a signal to keep the invocation on legacy — a heuristic, not a guarantee. Any future change to Typer flags could resurrect an "the CLI now silently means something different" incident.
  • DRY / minimal API violation. Two engines, two argument surfaces, two SIGINT/help/exit-code stories, a 375-line argparse builder that duplicates flag definitions the Typer commands already have. Every new subcommand has to decide both whether it registers in Typer and whether the legacy dispatcher already claims a flag with the same short form. AGENTS.md says "Simpler · More extensible" — this is neither.
  • The routing layer itself is now a load-bearing safety net. _LEGACY_VERBS_FALLBACK (a hand-coded frozenset) exists so that a broken import of the authoritative legacy oracle doesn't cost customers money. A safety net over a design choice usually means the design should change.

Violates simpler (774 lines to decide which parser runs), minimal API (two hidden flag surfaces per invocation), and — because misclassification directly bills the user — production-ready.

Proposed fix

Fold the legacy engine into Typer as a set of deprecated-flag adapters, delete the dual dispatch, and let Typer own argv resolution for everything:

python
# cli/legacy/adapter.py  (new; the ONLY legacy-flag entry point)
# Each formerly-legacy flag becomes a Typer command that emits a
# deprecation warning and calls the same handler the legacy module
# already implemented, keeping every behaviour intact for one release.

@app.command("run")
def run_cmd(
    target: st