کلاس ExternalMessage در بسته‌ی پایتونی کدکس، محتوای بی‌اعتماد را با نام ابزار فرستنده به نوبت بعدی می‌دهد: در تاریخچه‌ی همان نوبت می‌نشیند، اما با اقتدار در سطح ابزار و نه در سطح دستور. سه فیلد دارد و یکی از آن‌ها اختیاری است. یک نام ابزار خالی پیش از هر درخواست شبکه رد می‌شود. زمان خواندن داده: ۲۸ سپتامبر ۲۰۲۶.

کلاس سه فیلدی است و همین تقریباً همه‌ی توضیح است

در بسته‌ی openai-codex نسخه‌ی ۰٫۱۵۸٫۰ در رجیستری، تعریف کلاس بیست خط است و هر سه سطرش کار می‌کند. عدد نسخه از برچسب انتشار ۰٫۱۵۷٫۱ و تاریخچه‌ی انتشار کدکس پی گرفته شد، چون هر دو در همان خانواده‌ی شماره‌گذاری ۰٫۱۵ حرکت می‌کنند:

@dataclass(slots=True)
class ExternalMessage:
    """Untrusted content supplied by another agent, tool, or application.

    Content has tool-level authority, below user and developer instructions.
    It does not establish user authorization or approval.
    """
    tool_name: str
    content: str | Sequence[JsonObject | FunctionCallOutputContentItem]
    namespace: str | None = None

سه سطر اولِ مستندات، پاسخ همان پرسشی است که این پست می‌پرسد. محتوا اقتدار در سطح ابزار دارد، یعنی پایین‌تر از دستور کاربر و دستور توسعه‌دهنده. و صریح‌تر از آن: اقتدار کاربر و تأیید او را ایجاد نمی‌کند. یعنی محتوایی که از یک صفحه‌ی وب یا خروجی یک ابزار می‌آید، نمی‌تواند خودش را مجاز جلوه دهد.

تفاوت سه فیلد در عمل این است که دو تای اول هر دو لازم‌اند و سومی برای تفکیک. اگر دو سرور متفاوت هر دو ابزاری به نام search داشته باشند و namespace ندهید، در تاریخچه دو فراخوانی search غیرقابل تشخیص‌اند. با دادن نام فاصله، هرکدام رد خودش را دارند.

پیاده‌سازی هم این را جدی می‌گیرد: فیلد namespace مستقیم در شیء خروجی ابزار روی سیم می‌نشیند و مقدار پیش‌فرضش همان None است، نه رشته‌ی تهی. یعنی نبودنش در سیم هم قابل تشخیص است.

دو فیلد اجباری‌اند. tool_name نام ابزاری است که این محتوا را تحویل داده، و content خودِ محتواست. فیلد سوم namespace اختیاری است و مقدار پیش‌فرضش None است. دلیل وجودش روشن است: دو ابزار هم‌نام در دو فضای نام متفاوت، بدون آن یکی به نظر می‌رسند.

این کلاس در شبکه چه شکلی می‌شود

نکته‌ای که از تعریف کلاس معلوم نیست، شکل واقعی روی سیم است. تابع تبدیل ورودی نشان می‌دهد که یک پیام بیرونی با ورودی متنی کاملاً متفاوت رفتار می‌کند: لیست ورودی خالی می‌شود و به‌جای آن یک خروجی ابزار پر می‌شود. هر دو را روی بسته‌ی نصب‌شده اجرا کردم:

msg = ExternalMessage(tool_name="grep", content="total: 0 in README.md")
wire, tool_output = _to_wire_turn_input(msg)
wire       -> []
tool_output -> TurnToolOutput(name='grep', namespace=None,
                                output='total: 0 in README.md')

text_wire, text_tool = _to_wire_turn_input(TextInput("hi"))
text_wire   -> [{'type': 'text', 'text': 'hi'}]
text_tool   -> None

این تفاوت بنیادی است. یک پیام معمولی، ورودی نوبت است. یک پیام بیرونی، پاسخ به یک فراخوانی ابزار است و فهرست ورودی را خالی می‌گذارد. مدل آن را در جای خودش در تاریخچه می‌بیند، بدون آنکه مثل یک پیام تازه به نظر برسد. همین تمایز ورودی نوبت و خروجی ابزار، در رابط رسمی سرور هم یک قرارداد جداگانه است: هر دو شکل در راهنمای سرور اپ کدکس از هم تفکیک شده‌اند.

همین تابع یک اعتبارسنجی هم دارد که پیش از هر ارتباط شبکه‌ای اجرا می‌شود. نام ابزار خالی یا فقط فاصله، خطا می‌دهد:

refused : ''    -> ExternalMessage.tool_name must be a nonempty string
refused : '   ' -> ExternalMessage.tool_name must be a nonempty string

آستانه‌ی «فقط فاصله» عمدی است، ولی درباره‌ی فاصله‌ی ابتدا و انتها چیزی نمی‌گوید. یک نام مثل " grep" از این دروازه رد می‌شود. اگر نام ابزار را از ورودی کاربر یا یک فایل پیکربندی می‌خوانید، خودتان نرمالش کنید.

هر دو شکل، یک فراخوانی

امضای run و turn هر دو نوع ورودی را می‌پذیرند، پس لازم نیست مسیر جداگانه‌ای برای محتوای بی‌اعتماد بسازید. هر دو مستنداتشان همین را تکرار می‌کنند: پیام بیرونی اقتدار کاربر نمی‌دهد.

from openai_codex import Codex, ExternalMessage, Sandbox

with Codex() as codex:
    thread = codex.thread_start(sandbox=Sandbox.workspace_write)

    # a normal instruction from the user
    result = thread.run("Check whether main mentions the old flag name.")

    # now hand the model untrusted text with tool-level authority
    turn = thread.turn(ExternalMessage(
        tool_name="grep",
        content="src/main.py:12:  old_flag = True\nREADME.md:3: --old-flag removed",
    ))
    result = turn.run()
    print(result.final_response)

نکته‌ی عملی در ترتیب است. پیام اول دستور است و بالاترین اقتدار را دارد. پیام دوم داده است. اگر این دو را برعکس بفرستید، یعنی اول محتوای بی‌اعتماد و بعد دستور، مدل داده را در جایگاه دستور می‌بیند و همین‌چیزی می‌شود که این کلاس برای جلوگیری از آن ساخته شده.

محتوای ساختاریافته هم پذیرفته می‌شود. نوع content فقط رشته نیست و دنباله‌ای از دیکشنری‌های سازگار با رابط پاسخ هم می‌پذیرد. با namespace و آیتم‌های ساختاریافته، شکل سیمی این‌گونه درآمد:

msg = ExternalMessage(
    tool_name="http_get",
    namespace="fetch",
    content=[
        {"type": "input_text",  "text": "HTTP/1.1 200 OK"},
        {"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="},
    ],
)
name        -> http_get
namespace    -> fetch
output      -> [FunctionCallOutputContentItem(... InputText ... 'HTTP/1.1 200 OK'),
                   FunctionCallOutputContentItem(... InputImage ...)]

یعنی لازم نیست برای دیدن عکس یا جدول، متنش را به رشته تبدیل کنید. دیکشنری ساده کافی است و بسته پوششی لازم برای تبدیل نمی‌سازد. همین ساختار در راهنمای کدکس به‌عنوان سرور MCP هم به‌عنوان نتیجه‌ی یک فراخوانی برگردانده می‌شود و کلاینت آن را در فیلد structuredContent می‌خواند.

چه چیزی را با این کلاس بفرستید و چه چیزی را نه

قاعده‌ی تصمیم ساده است: اگر محتوا از بیرون آمده و شما آن را کنترل نمی‌کنید، با پیام بیرونی بفرستید. اگر خودتان تولیدش کرده‌اید و به آن اعتماد دارید، ورودی معمولی. همین قاعده پشت کانال ابزار در راهنمای اتصال به سرورهای MCP هم هست: هرچه از بیرون می‌آید، خروجی ابزار است و نه دستور کاربر.

محتواشکل درستدلیل
خروجی جست‌وجوی محلیExternalMessageابزار شماست، ولی نام ابزار را ثبت می‌کند
متن یک صفحه‌ی وبExternalMessageمحتوای بی‌اعتماد به‌معنای واقعی کلمه
خروجی پیام‌رسانExternalMessageنویسنده‌اش کاربر نیست و مجوز کاربر ندارد
پاسخ مدل زیرمجموعهExternalMessageمدل دیگر ممکن است هالوسینیشن کرده باشد
دستور کاربرTextInputبالاترین اقتدار، تنها منبع مجوز
دستور ثابت سیستمیTextInputسیاست خودتان است، نه داده
  • خروجی یک ابزار خودتان: ExternalMessage، چون نام ابزار در تاریخچه ثبت می‌شود و بعداً می‌دانید پاسخ از کجا آمده.
  • متنی که یک کاربر دیگر نوشته: ExternalMessage، چون آن کاربر مجوز کاربر شما نیست.
  • خلاصه‌ی یک ایجنت فرزند: ExternalMessage، چون مدل دیگر ممکن است چیزی ساخته باشد که درست نیست.
  • دستور خودتان: TextInput، چون بالاترین اقتدار است و تنها منبع مجوز.

سطر چهارم جدول، کاربردی‌ترین سطر است. وقتی یک ایجنت فرزند را صدا می‌زنید و خلاصه‌اش را به والد برمی‌گردانید، آن خلاصه داده است نه دستور. اگر با ورودی معمولی بفرستید، متن فرزند در سطح دستور می‌نشیند و می‌تواند مسیر بعدی را تعیین کند.

یک آزمون عملی که ارزش اجرا دارد، همین مقایسه است. اگر شکل درست را به کار ببرید، محتوای بیرونی در تاریخچه‌ی همان نوبت می‌نشیند و دستور شما بالای آن می‌ماند. اگر شکل نادرست را به کار ببرید، همان متن به‌عنوان بخشی از دستور تفسیر می‌شود. تفاوت در متن خام نیست؛ در جایگاه آن است، و همین‌جاست که آزمون منفی معنا پیدا می‌کند.

آزمون منفی را هم می‌شود نوشت: یک متن بی‌اعتماد بسازید که در آن ادعای مجوز شده باشد، مثلاً جمله‌ای که می‌گوید کاربر اجازه‌ی حذف همه‌ی فایل‌ها را داده است. اگر آن را با پیام بیرونی بفرستید، مدل باید آن را داده ببیند نه دستور. اگر در همان نشست و با همان متن، به‌جای ورودی بیرونی از ورودی متنی استفاده کنید، رفتارش باید فرق کند. تفاوت مشاهده‌شده همان چیزی است که این کلاس می‌فروشد.

در مقابل، یک وسوسه‌ی رایج این است که محتوای بیرونی را در خود دستور کاربر جاسازی کنید، مثلاً با الحاق متن به رشته‌ی دستور. این دقیقاً همان چیزی است که این کلاس برای جلوگیری از آن ساخته شده: جایگاه در تاریخچه تعیین می‌کند چه سطحی از اقتدار به محتوا داده شود، و الحاق متن این تمایز را از بین می‌برد.

مرزهای این بررسی

آنچه این پست ادعا می‌کند محدود است: شکل سیمی، اعتبارسنجی نام ابزار و پذیرش محتوای ساختاریافته، همه با اجرای مستقیم روی بسته‌ی نصب‌شده. آنچه ادعا نمی‌کنم این است که مدل در عمل چگونه به این سطوح اقتدار پاسخ می‌دهد. برای آن باید یک نشست واقعی با ورودی متنی فراخوانی شود، و این آزمایش اینجا اجرا نشد.

دو نکته‌ی دیگر هم هست. بسته‌ی openai-codex به یک بسته‌ی جانبی برای اجرای خط فرمان نیاز دارد که هنگام نصب جداگانه می‌آید؛ بدون آن، وارد کردن کتابخانه کار می‌کند ولی اجرای نشست ممکن است نیازمند آن باینری باشد. و source در امضای turn هست، ولی مستنداتش صریح می‌گوید فقط برچسب می‌زند و هیچ اقتداری نمی‌دهد؛ با ExternalMessage اشتباه نگیریدش. یک نکته‌ی عملی دیگر هم این است که شماره‌ی نسخه را در بازبینی‌ها ثابت نگه دارید؛ همین سطوح در کدکس سریع تغییر می‌کنند و نشانه‌ی آن دو تغییر پشت‌سرهم است: هشدار شماره‌ی ۳۹۶۵۷ و سپس حذف کامل در شماره‌ی ۴۲۹۹۳، همان چیزی که در پست حذف سرور MCP بررسی شده است.

جمع‌بندی: اگر محتوایی را از بیرون به کدکس می‌دهید، ExternalMessage راه درست است، چون جایگاهش در تاریخچه را تعیین می‌کند و نام ابزار فرستنده را ثبت می‌کند. اگر محتوا را خودتان تولید کرده‌اید یا کاربر خواسته، ورودی متنی معمولی بماند. تفاوت بین این دو، تفاوت بین داده و دستور است.

منابع

  1. انتشار ۰٫۱۵۷٫۱ خط فرمان کدکس — مبنای شماره‌گذاری نسخه‌ی بسته‌ی پایتونی
  2. انتشار ۰٫۱۵۶٫۰ — یکی از نسخه‌های میانی پیش از نسخه‌ی بررسی‌شده
  3. انتشار ۰٫۱۵۵٫۰ — یکی از نسخه‌های میانی پیش از نسخه‌ی بررسی‌شده
  4. انتشار ۰٫۱۵۴٫۰ — یکی از نسخه‌های میانی پیش از نسخه‌ی بررسی‌شده
  5. انتشار ۰٫۱۵۳٫۴ — آخرین اصلاح پیش از زنجیره‌ی نسخه‌های بعدی
  6. تاریخچه‌ی انتشار کدکس — فهرست نسخه‌ها و شماره‌ی مربوط به هرکدام
  7. راهنمای سرور اپ کدکس — تفکیک رویدادهای نوبت از خروجی ابزار
  8. راهنمای کدکس به‌عنوان سرور MCP — شکل ساختاریافته‌ی نتیجه‌ی فراخوانی
  9. راهنمای اتصال کدکس به سرورهای MCP — اینکه خروجی ابزار چه سطحی از اقتدار دارد
  10. ادغام شماره‌ی ۳۹۶۵۷ — افزودن هشدار برای زیرفرمان منسوخ
  11. ادغام شماره‌ی ۴۲۹۹۳ — حذف کامل زیرفرمان منسوخ
  12. صفحه‌ی مهاجرت پس از حذف سرور MCP — آنچه یکپارچه‌سازی‌های قدیمی باید کنار بگذارند