الدرس 5 من 10

أدوات حقيقية وآمنة

الأدوات هي اللي بتفرق agent لعبة عن agent مفيد.

١. آلة حاسبة آمنة

الموديلات مش دقيقة في الحسابات الكبيرة، فادّيله آلة حاسبة. بس أوعى تستخدم eval() على نص جاي من الموديل: ده بيسمح بتنفيذ أي كود بايثون! الحل إنك تحلل التعبير وتسمح بالعمليات الحسابية بس:

calculator_tool.py
import ast
import operator as op

ALLOWED = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul,
           ast.Div: op.truediv, ast.Pow: op.pow, ast.USub: op.neg}

def calculate(expression: str) -> str:
    """Safely evaluate a math expression like '1850 * 0.14 + 75'."""
    def ev(node):
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp) and type(node.op) in ALLOWED:
            return ALLOWED[type(node.op)](ev(node.left), ev(node.right))
        if isinstance(node, ast.UnaryOp) and type(node.op) in ALLOWED:
            return ALLOWED[type(node.op)](ev(node.operand))
        raise ValueError("Only numbers and + - * / ** are allowed")
    return str(round(ev(ast.parse(expression, mode="eval").body), 2))

print(calculate("1850 * 0.14 + 75"))
print(calculate("(640 - 64) / 3"))
try:
    calculate("__import__('os').system('rm -rf /')")    # an attack attempt
except ValueError as e:
    print("blocked:", e)

٢. قراية ملفات في فولدر محدد

لو اديت الـ agent أداة تقرا ملفات، لازم تحبسه في فولدر معين. من غير كده ممكن يطلب ../../.env ويقرا الأسرار. الهجوم ده اسمه path traversal:

file_tool.py
from pathlib import Path

DOCS_DIR = Path("store_docs").resolve()
DOCS_DIR.mkdir(exist_ok=True)
(DOCS_DIR / "refund_policy.txt").write_text("Returns accepted within 14 days.", encoding="utf-8")

def read_doc(filename: str, max_chars: int = 2000) -> str:
    """Read a document from the store's docs folder."""
    path = (DOCS_DIR / filename).resolve()
    if not path.is_relative_to(DOCS_DIR):            # escaped the allowed folder?
        raise PermissionError("Access outside store_docs is not allowed")
    if not path.exists():
        available = ", ".join(p.name for p in DOCS_DIR.iterdir())
        raise FileNotFoundError(f"Not found. Available files: {available}")
    return path.read_text(encoding="utf-8")[:max_chars]  # cap the size

print(read_doc("refund_policy.txt"))
for bad in ["../.env", "shipping.txt"]:
    try:
        read_doc(bad)
    except Exception as e:
        print(f"{bad!r}: {e}")

لاحظ رسالة «Not found» فيها أسماء الملفات المتاحة: كده الموديل يقدر يصلح طلبه لوحده في الخطوة الجاية. ولاحظ max_chars: نتايج الأدوات بتدخل الـ context وبتتحاسب tokens، فمتبعتش ملف ٥٠٠ صفحة.

٣. قاعدة بيانات: قراية بس

أداة بتنفذ SQL من الموديل خطيرة لو تقدر تمسح أو تعدل. افتح الاتصال read-only، وده بيمنع أي تعديل على مستوى قاعدة البيانات نفسها مش على مستوى الكود بس:

sql_tool.py
import sqlite3

# setup: a small orders database (normally this already exists)
setup = sqlite3.connect("store.db")
setup.executescript("""
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (id TEXT, city TEXT, total REAL, status TEXT);
INSERT INTO orders VALUES ('1024','Cairo',1850,'shipping'), ('1031','Giza',640,'delivered'),
                          ('1040','Aswan',2300,'delivered'), ('1043','Cairo',420,'cancelled');
""")
setup.commit(); setup.close()

def query_orders(sql: str) -> str:
    """Run a read-only SQL query on the orders table (id, city, total, status)."""
    conn = sqlite3.connect("file:store.db?mode=ro", uri=True)   # READ-ONLY connection
    try:
        rows = conn.execute(sql).fetchmany(50)                  # cap the rows
        return str(rows)
    finally:
        conn.close()

print(query_orders("SELECT city, SUM(total) FROM orders WHERE status='delivered' GROUP BY city"))
try:
    query_orders("DELETE FROM orders")
except sqlite3.OperationalError as e:
    print("blocked:", e)

٤. API خارجي

api_tool.py
import requests

def get_exchange_rate(currency: str) -> str:
    """Get today's rate of a currency (e.g. USD) against the Egyptian pound."""
    try:
        r = requests.get(f"https://api.example-rates.com/latest/{currency.upper()}",
                         timeout=5)                   # ALWAYS set a timeout
        r.raise_for_status()
    except requests.RequestException as e:
        raise RuntimeError(f"Rate service unavailable, try again later ({type(e).__name__})")
    return str(r.json()["rates"]["EGP"])
💡 قواعد لأي أداةtimeout لأي طلب شبكة. حد أقصى لحجم النتيجة. رسايل خطأ مفيدة للموديل. أقل صلاحيات ممكنة (read-only لو القراية كفاية). والأدوات اللي بتغيّر حاجات ليها درس لوحدها في الأمان.
الدرس اللي فات