Rival: Adversarial Second-Opinion Code Review

    1

    Adversarial code reviews using a panel of structurally diverse models via OpenRouter or local Ollama.

    Free

    Security scanned

    Works with the AI Agents you already use

    Claude CodeClaude CodeCursorCursorCodex CLICodex CLIMuseMuseDotsDots+22 more

    Rival: Adversarial Second-Opinion Code Review

    Example session with this skill installed

    /rival --panel

    Review billing.py:

    import time
    
    _cache = {}
    
    def get_invoices(db, customer_id, page=1, per_page=50):
        offset = page * per_page
        rows = db.execute(
            f"SELECT * FROM invoices WHERE customer_id = '{customer_id}' "
            f"ORDER BY created_at LIMIT {per_page} OFFSET {offset}"
        ).fetchall()
        return rows
    
    def apply_discount(total, percent):
        if percent > 100:
            percent = 100
        return round(total - total * percent / 100)
    
    def cached_balance(db, customer_id, ttl=60):
        hit = _cache.get(customer_id)
        if hit and time.time() - hit[0] < ttl:
            return hit[1]
        balance = sum(r["amount"] for r in get_invoices(db, customer_id))
        _cache[customer_id] = (time.time(), balance)
        return balance
    
    • Read your context and instructions
    • Compiled the rival

    Rival Panel Review (2 models, sequential chain)

    Panel reduced to 2 models: google/gemma-4-31b-it:free was rate-limited (HTTP 429 after 5 retries), so it was skipped.

    Models: nvidia/nemotron-3-ultra-550b-a55b:free → nvidia/nemotron-3-super-120b-a12b:free

    Final reviewer: nvidia/nemotron-3-super-120b-a12b:free

    Independent review of the original code

    Below is a fresh walk‑through of the supplied snippet, grouped by the same categories the first reviewer used. I note where I agree, where I see a different severity or nuance, and I add issues that were not mentioned at all.


    ✅ Issues I agree with (same finding, same or similar severity)

    #Issue (as noted by reviewer)Why I agree
    1SQL Injection – direct interpolation of customer_id, per_page, offsetClassic injection; any control over those strings yields arbitrary SQL.
    2Pagination offset off‑by‑one (offset = page * per_page)Page 1 skips the first per_page rows; the usual 1‑based formula is (page‑1)*per_page.
    3cached_balance only sums the first pageThe helper is called with its defaults (page=1, per_page=50); >50 invoices are ignored.
    4Global cache not thread‑safeConcurrent get/set on a plain dict can race, corrupt entries or lose updates.
    5Floating‑point money in apply_discountBinary float cannot represent cents exactly; rounding errors accumulate.
    6Unbounded cache growthEvery distinct customer_id stays forever → OOM in long‑running services.
    7No pagination input validationNegative or huge page/per_page produce bad offsets, DoS or wrong data.
    8apply_discount accepts negative percent-50 → 150 % of total (a surcharge).
    9TTL logic inconsistent across callersTTL is only a read‑time check; the stored entry has no expiry, so different callers see different “freshness”.
    dermalSELECT * fragilityIf the DB driver returns tuples, r["amount"] fails; schema changes can break silently.

    ⚠️ Issues where I see a different nuance or severity

    #Reviewer’s claimMy take / severity adjustment
    5Floating‑point money – marked P1I would elevate this to P0 in any financial‑context code because a rounding error can lead to incorrect balances, failed audits, and regulatory penalties.
    9TTL inconsistency – marked P2While not a security bug, this is a logic bug that can cause stale data to be served indefinitely (if a caller with a large TTL wins the race). I’d keep it P2 but note that it can manifest as a subtle correctness issue in multi‑tenant services.
    10SELECT * – marked P3I agree it’s low‑priority if the DB driver always returns row‑objects with named columns (e.g., SQLAlchemy Core, sqlite3 Row). However, many DB‑API drivers return plain tuples; the code as written would raise TypeError. I’d keep it P3 but add a note that the code relies on a specific driver behavior.
    2Pagination offset – marked P1The bug is indeed a correctness issue (missing first page), but it does not lead to a crash or security exposure. I’d keep it P1.
    7No pagination validation – marked P2I agree; missing validation can cause DoS via huge offsets or negative offsets that error.

    🆕 New issues the first reviewer did not mention

    CategoryIssueDetails & ImpactSuggested fix
    Arithmetic / roundingapply_discount uses round(val) (no ndigits) → rounds to nearest integer, discarding cents.For money this is catastrophic: a $10.50 invoice with a 10 % discount becomes round(10.50 - 1.05) = round(9.45) = 9 → loses $0.45.Use round(value, 2) or, preferably, Decimal quantisation (see floating‑point fix).
    Type safetyNo validation that total and percent are numeric (int/float/Decimal).Passing a string (total="100") raises TypeError at runtime; passing None raises TypeError on arithmetic.Add if not isinstance(total, (int, float, Decimal)): raise TypeError (or convert via Decimal(str(total))).
    Negative totalsapply_discount does not guard against a negative total.A negative total would produce a negative discount (i.e., increase the amount owed), which is likely unintended.Clamp total to max(total, 0) or validate business rules upstream.
    Non‑integer pagination argsIf page or per_page are strings, the expression page * per_page repeats the string (e.g., "2"*50 → "222…") before being injected into SQL, causing a SQL syntax error or unexpected behaviour.Leads to runtime exceptions and potentially exposes internal errors to users.Convert to int early: page = int(page); per_page = int(per_page) and catch ValueError.
    Extreme offset valuesEven after clamping per_page, a huge page (e.g., page=10**9) yields an enormous OFFSET that forces the DB to scan and skip billions of rows → severe performance degradation / DoS.Not caught by the existing validation if only per_page is bounded.Add an upper bound on page (e.g., if page > 1_000_000: page = 1_000_000) or calculate offset safely with checks for overflow.
    Cache stampede (thundering herd)On a cache miss, all concurrent threads will compute cached_balance afresh, potentially hammering the DB.Under high load this can spike DB latency and exacerbate the miss‑rate.Consider a locking pattern (double‑checked locking or cachetool's Lock) or use functools.lru_cache with a threading.Lock wrapper.
    Global cache visibility / test pollution_cache is a module‑level mutable; unit tests that call cached_balance leave state that can affect subsequent tests.Leads to flaky tests and hard‑to‑debug CI failures.Expose a clear_cache() function for tests, or switch to a per‑instance cache (e.g., pass a cache object in).
    Missing docstrings / type hintsThe code lacks any documentation or type annotations, making IDE assistance and static analysis harder.Lowers maintainability, especially in larger codebases.Add docstrings and PEP‑484 type hints (def get_invoices(db: Connection, customer_id: str, page: int = 1, per_page: int = 50) -> List[sqlite3.Row]: …).
    Assumption about row typeThe code accesses r["amount"], assuming the driver returns a mapping‑like row (e.g., sqlite3.Row or SQLAlchemy Row).If the driver returns a plain tuple, this raises TypeError: tuple indices must be integers or slices, not str.Either enforce a row factory that returns mappings (db.row_factory = sqlite3.Row) or use positional indexing (r[1] after verifying column order).
    No error handling for DB failuresIf db.execute raises (e.g., timeout, constraint violation), the exception propagates outward uncaught, possibly exposing stack traces.In a web service this could leak internal details or cause 500 responses.Wrap the DB call in a try/except and translate to a domain‑specific error or log and return a safe default.
    Cache entry stores a float balanceThe cached value is a Python float (the result of sum). Combined with the floating‑point issue above, the cached value can drift from the true decimal amount over time.Even if the TTL expires, the stored value is already imprecise.Store the balance as a Decimal (or integer cents) in the cache.
    TTL of zero or negativeIf ttl <= 0, the condition time.time() - hit[0] < ttl is never true, so the cache is effectively disabled but still stores entries that are never used.Wastes memory.Guard: if ttl <= 0: return sum(...) or skip caching altogether.
    Hard‑coded default per_page=50 in cached_balanceThe balance function cannot be tuned without changing the source; callers who need a different page size must reimplement the sum.Limits flexibility.Add per_page (and maybe page) as parameters to cached_balance or provide a dedicated get_all_invoices helper.
    Potential division by zero in discount calculationNot present directly, but if percent is a very large number, total * percent / 100 could overflow to inf (float) before the if percent > 100 clamp runs (because the clamp is after the multiplication in the original line? Actually the clamp is before the multiplication, so safe).Minor, but worth noting if the order ever changes.Keep the clamp before any arithmetic, as already done.
    Missing __all__ / public‑API definitionThe module exposes _cache and helper functions that may be imported unintentionally.Increases surface area for misuse.Define __all__ = ["get_invoices", "apply_discount", "cached_balance"] and keep _cache private (already is).

    📌 Summary

    Core security & correctness problems (SQL injection, pagination offset, incomplete balance, thread‑unsafe cache, floating‑point money) are all correctly identified by the first reviewer.

    I agree with all of those findings, though I would raise the

    floating‑point money issue to

    P0 and note a few severity adjustments.

    Newly discovered problems include the integer‑rounding bug in apply_discount, missing type/range checks for inputs, potential cache‑stampede, test pollution due to the global cache, and assumptions about row‑type that could cause runtime exceptions.

    • Addressing the new items—especially fixing the rounding to cents, validating/coercing pagination arguments, and making the cache thread‑safe with a bounded size—will bring the code to a production‑ready state for a financial‑oriented service.

    Feel free to let me know if you’d like a concrete patch that incorporates all of the above fixes.


    Individual reviewer outputs

    Reviewer 1: nvidia/nemotron-3-ultra-550b-a55b:free

    1. SQL Injection — Critical (Lines 7-9)

    f"SELECT * FROM invoices WHERE customer_id = '{customer_id}' "
    f"ORDER BY created_at LIMIT {per_page} OFFSET {offset}"
    

    Impact: Arbitrary SQL execution. customer_id, per_page, offset are interpolated directly. An attacker controlling customer_id (e.g., "1' OR '1'='1") bypasses authorization, reads all invoices, or drops tables.
    Fix: Use parameterized queries. Most DB-API drivers support ? or %s placeholders for values. For LIMIT/OFFSET, validate as integers first.

    
    # Example fix (sqlite3 style)
    offset = (page - 1) * per_page  # also fixes bug #2
    rows = db.execute(
        "SELECT * FROM invoices WHERE customer_id = ? ORDER BY created_at LIMIT ? OFFSET ?",
        (customer_id, per_page, offset)
    ).fetchall()
    

    2. Pagination Offset Calculation Wrong (Line 6)

    offset = page * per_page
    

    Impact: Page 1 returns records 51–100 (offset=50), skipping the first page entirely. Page 0 would work but is non-standard.
    Fix: offset = (page - 1) * per_page for 1-based paging. Validate page >= 1.

    3. cached_balance Only Sums First Page (Line 25)

    balance = sum(r["amount"] for r in get_invoices(db, customer_id))
    

    Impact: get_invoices defaults to page=1, per_page=50. Customers with >50 invoices return an incorrect (low) balance. Silent data corruption.
    Fix: Either paginate through all pages in cached_balance, or add a dedicated get_all_invoices / sum_invoices query:

    balance = db.execute(
        "SELECT COALESCE(SUM(amount), 0) FROM invoices WHERE customer_id = ?",
        (customer_id,)
    ).fetchone()[0]
    

    4. Global Cache Not Thread-Safe (Lines 1, 22, 27)

    _cache = {}  # module-level global
    

    Impact: In a multi-threaded server (Flask, FastAPI, gunicorn workers with threads), concurrent reads/writes to _cache cause race conditions: corrupted entries, KeyError, or stale data returned indefinitely.
    Fix: Use threading.Lock for the cache, or better, a thread-safe cache like functools.lru_cache (if single-process) or Redis/memcached for multi-process. At minimum:

    import threading
    _cache = {}
    _cache_lock = threading.Lock()
    
    def cached_balance(...):
        with _cache_lock:
            hit = _cache.get(customer_id)
            ...
            _cache[customer_id] = (time.time(), balance)
    

    5. Floating-Point Money in apply_discount (Line 16)

    return round(total - total * percent / 100)
    

    Impact: Binary floating-point cannot represent decimal fractions exactly. apply_discount(100.00, 33.33) → 66.67000000000001 → round() → 66.67 (sometimes), but edge cases like 2.675 round to 2.67 (banker's rounding). Financial audits will fail.
    Fix: Use decimal.Decimal with fixed precision:

    from decimal import Decimal, ROUND_HALF_UP
    
    def apply_discount(total, percent):
        total = Decimal(str(total))
        percent = Decimal(str(percent))
        if percent > 100:
            percent = Decimal('100')
        discount = total * percent / Decimal('100')
        return (total - discount).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    

    6. No Cache Size Limit / Eviction (Line 1)

    _cache = {}
    

    Impact: Unbounded memory growth. Every unique customer_id ever queried stays in memory forever. Long-running process → OOM kill.
    Fix: Use functools.lru_cache(maxsize=...) on a helper, or cachetools.TTLCache(maxsize=10000, ttl=60).

    7. No Input Validation on Pagination (Lines 4-5)

    def get_invoices(db, customer_id, page=1, per_page=50):
    

    Impact: page=0 → negative offset (some DBs error, some return wrong data). page=-1 → huge positive offset (DoS via full table scan). per_page=1000000 → memory exhaustion.
    Fix:

    if page < 1: page = 1
    if per_page < 1 or per_page > 1000: per_page = 50  # clamp
    

    8. apply_discount Accepts Negative Percent (Line 13)

    if percent > 100:
        percent = 100
    

    Impact: percent=-50 → 150% of total returned (surcharge). Likely unintended.
    Fix: Clamp lower bound: percent = max(0, min(100, percent)).

    9. Cache TTL Logic Inconsistent Across Callers (Lines 22-24)

    hit = _cache.get(customer_id)
    if hit and time.time() - hit[0] < ttl:
    

    Impact: Caller A uses ttl=60, Caller B uses ttl=300. Entry cached at T=0. At T=100, Caller B sees 100 < 300 → returns 100s-stale data. Cache entry has no TTL metadata; TTL is a read-time policy.
    Fix: Store TTL with entry, or use a cache library that handles this.

    10. SELECT * Fragility (Line 7)

    SELECT * FROM invoices ...
    

    Impact: Schema change (column added/removed/reordered) breaks r["amount"] if amount is not explicitly selected.
    Fix: SELECT id, amount, created_at, ... explicitly.


    Summary of Fixes Priority

    PriorityIssue
    P0SQL Injection (1)
    P0cached_balance sums only first page (3)
    P0Thread-unsafe global cache (4)
    P1Pagination offset off-by-one (2)
    P1Floating-point money (5)
    P1Unbounded cache memory (6)
    P2No pagination validation (7)
    P2Negative discount (8)
    P2TTL inconsistency (9)
    P3SELECT * (10)

    Connects securely to your tools. The creator never sees your data.

    About this skill

    Code reviews often suffer from "LLM optimism," where agents overlook critical bugs or security flaws. Rival forces an adversarial perspective by routing your code through structurally diverse model families (whichever free families are healthy that day, such as Nemotron, Gemma, Qwen or Llama) to find what you missed.

    What it does

    • Adversarial Review identifies logic errors, off-by-one mistakes, and security vulnerabilities instead of giving generic praise.
    • Multi-Model Panels chains up to three different models to build on each other's findings or provide independent blind reviews.
    • Automated Roster Discovery ranks and caches available free-tier models by size and family diversity every 24 hours.
    • Local Compute Support enables private reviews using local Ollama models when code cannot leave the machine.
    • Task Delegation routes non-review requests to specific models without adversarial prompting for general implementation work.

    How it works

    1. Rival Discovery runs a script that ranks healthy OpenRouter free models by size, one per family, and caches the roster for 24 hours (pinned paid models rank first). With --local it uses your Ollama model instead.
    2. Context Gathering pulls the current git diff or specific file contents to prepare the review target.
    3. Panel Execution calls models sequentially with built-in delays to respect free-tier rate limits, passing findings from one to the next.
    4. Consolidation presents a severity-ranked summary (CRITICAL to LOW) alongside the raw outputs from each reviewer.

    Frameworks & tools

    Uses Bash scripts, curl, and jq to interface with the OpenRouter API or local Ollama instances.

    Why this beats prompting it yourself

    Manual prompting rarely forces the model to disagree with itself or its peers. Rival automates the cross-family validation logic and handles the rate-limit delays required to get deep, multi-perspective reviews for free.

    Use cases

    • Pre-merge Audit to find edge cases in a complex PR before human review.
    • Security Check to scan for injection or auth bypasses using diverse model logic.
    • Offline Review to maintain productivity and code privacy using Ollama.
    • Consensus Building to resolve technical disagreements via a three-model blind panel.

    Known limitations

    Limited to one file or diff at a time; for a whole-repo audit see Crucible. Free-tier models get rate-limited: panels space calls 8-10 seconds apart, and a model that stays at HTTP 429 is skipped and the reduced panel is announced. Code under review is sent to openrouter.ai unless you use --local.

    How to use

    Skills on Agensi run inside your AI agent. Copy the prompt, paste it into your agent, and it connects to Agensi and installs this skill for you. If a payment is needed, your agent shows it to you first.

    Reviews

    No reviews yet

    Be one of the first to try it. Every listed skill passes our trust checks below.

    Security scanned

    Passed our 8-point scan before listing

    Fresh listing

    Recently published to Agensi

    Free forever

    No account required to browse

    Trust & safety

    Security scanned

    Verified clean 7 days ago

    • Free to download with an account

    Requires

    Openrouter

    Listed7 days ago

    What's inside

    Frequently Asked Questions