◆ مهندسی هوش مصنوعی با Codex

ماژول ۳ · هفته ۴ تا ۶ · حدود ۸ ساعت

#ماژول ۳. ارزیابی (Evals) و تحلیل خطا

بخشی از صفحه اصلی دوره. حدود ۸ ساعت: ۳ ساعت خواندن، ۵ ساعت کار عملی. بررسی‌شده با Codex CLI 0.167.1 و promptfoo 0.125.1 در ۱۱ نوامبر ۲۰۲۶.

#شروع سریع (۱۵ دقیقه)

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

در ماژول ۰ چهارده فایل fixture ساختی و کنارشان fixtures/labels.json را. هنوز هیچ مدلی روی آن‌ها امتحان نشده. حالا Codex را یک بار به‌عنوان reviewer روی همه fixtureها اجرا کن، با یک پرامپت کوتاه و بدون هیچ قالبی برای خروجی. این را از ریشه ریپو sentinel بزن:

bash
mkdir -p results/quick
blank=$(mktemp -d)        # an empty folder: no repo, no file names, no labels.json nearby
for f in fixtures/k8s/* fixtures/js/*; do
  codex exec --ephemeral --skip-git-repo-check -C "$blank" -m gpt-6-luna \
    "You are a security reviewer. List the security problems in the file in the <stdin> block, one short line each. Write NONE if there are none." \
    < "$f" > "results/quick/$(basename "$f").txt"
  echo "== $f"; cat "results/quick/$(basename "$f").txt"
done

با codex exec یک تسک بدون تعامل اجرا می‌شود و سندباکس پیش‌فرضش read-only است. محتوای فایل از stdin می‌رود و Codex آن را به شکل یک بلوک <stdin> ته پرامپت می‌چسباند. روی stdout فقط پیام آخر چاپ می‌شود، پس در هر فایل .txt فقط خود جواب هست. روی هم ۱۴ درخواست است، همه روی GPT-6 Luna که ارزان‌ترین مدل پلن توست.

حالا fixtures/labels.json را کنار خروجی‌ها باز کن و دستی نمره بده. جواب یک فایل وقتی درست است که همه مشکل‌های برچسب‌خورده‌اش را گفته باشد. برای ۴ فایل تمیز، درست یعنی جواب NONE باشد یا چیزی خیلی نزدیک به آن.

خط پایه:

  • تاریخ:
  • فایل‌هایی که کاملاً درست بودند:
  • فایل‌های تمیزی که باز هم مشکلی برایشان گزارش شد:
  • دقیقه‌هایی که نمره‌دادن دستی طول کشید:
  • جوابی که کمتر از همه مطمئن بودی چطور نمره بدهی:

فیلد آخر را سرسری پر نکن. تقریباً همه به دست‌کم یک جواب می‌خورند که با بله و نه نمره نمی‌گیرد، مثل «resource limits ندارد» روی k01 یا «تگ ایمیج digest نیست» روی k07. کل این ماژول درباره همین تردید است. آخرش اسکریپتی داری که همین نمره‌دادن را در کمتر از یک ثانیه انجام می‌دهد، آن هم برای پنج اجرا به جای یکی، و معنی «درست» را هم مکتوب کرده‌ای.

#چرا مهم است

تو همین الان هم هر روز به کار Codex نمره می‌دهی. دیفش را می‌خوانی، به نظرت درست می‌آید و می‌روی سراغ کار بعدی. این یعنی یک نمونه که با حال و حوصله همان لحظه نمره گرفته. با این روش نمی‌فهمی پرامپت تازه، مدل تازه یا AGENTS.md تازه کار را بهتر کرده یا فقط عوضش کرده.

در ابزار امنیتی قضیه جدی‌تر است. اگر reviewer یک privileged: true را جا بیندازد، تو هیچ‌وقت از این false negative خبردار نمی‌شوی. اگر هم روی هر مانیفستی هشدار بدهد، تیم از هفته دوم دیگر نگاهش نمی‌کند. پس هر دو عدد را لازم داری، برای هر نوع مشکل جدا. چند بار هم باید اجرا کنی، چون مدل دو بار پشت سر هم یک جواب نمی‌دهد.

در این ماژول fixtureهای ماژول ۰ را به یک eval واقعی تبدیل می‌کنی و چیزی که تست می‌شود خود Codex است. در ماژول ۱ دیدی context چقدر کمکش می‌کند، در ماژول ۲ مشخص کردی به چه چیزهایی دست بزند، و حالا نوبت این است که ببینی چقدر خوب review می‌کند. scorer این ماژول تا آخر دوره به کارت می‌آید. ماژول ۴ آن را در یک حلقه و در CI اجرا می‌کند، ماژول ۵ آن را روی ایجنت خودت اجرا می‌کند و ماژول ۷ با آن چک می‌کند که دفاع‌هایت تشخیص را خراب نکرده باشند.

#تصویر کلی

flowchart LR
  FX["fixtures/<br/>۱۴ فایل"] --> RUN["eval/run.ts<br/>codex exec × ۵"]
  RUN --> RES["results.jsonl<br/>+ traces/"]
  LAB["labels.json<br/>+ allowed.json"] --> SC["eval/score.ts"]
  RES --> SC
  SC --> NUM["P / R / F1<br/>pass@k، pass^k"]
  RES --> EA["تحلیل خطا<br/>خواندن traceها"]
  EA --> FIX["تغییر پرامپت<br/>یا برچسب‌ها"]
  FIX --> RUN
  RES --> JD["eval/judge.ts"]
  JD --> AG["eval/agree.ts<br/>مقایسه با تو"]

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

#مفهوم ۱. واژه‌ها و دو نوع eval

در راهنمایی که Anthropic ژانویه ۲۰۲۶ درباره eval ایجنت‌ها نوشته، چند اصطلاح هست که به کار این دوره خوب می‌آید:

واژه معنی در sentinel
تسک (task) یک ورودی با جواب درست مشخص یک fixture با برچسب‌هایش
تلاش (trial) یک بار امتحان یک تسک یک فراخوانی codex exec روی یک fixture
ترنسکریپت (transcript) همه اتفاق‌های یک trial جریان رویدادهای --json در traces/
نتیجه (outcome) خروجی نهایی که نمره می‌گیرد شیء JSON با findings
نمره‌دهنده (grader) کد یا مدلی که نتیجه را نمره می‌دهد فایل score.ts (کد) و judge.ts (مدل)
هارنس (harness) چیزی که trialها را اجرا و نتیجه‌ها را جمع می‌کند فایل run.ts، بعداً promptfoo و حلقه SDK در ماژول ۴

با همین ابزار دو جور eval می‌شود ساخت که به دو کار مختلف می‌آیند:

  • ارزیابی توانایی (capability eval) می‌پرسد «از پسش برمی‌آید یا هنوز نه؟». نمره‌اش باید پایین شروع شود تا جا برای بالا رفتن باشد. pull requestهای کاشته‌شده ماژول ۴ از همین نوع‌اند.
  • ارزیابی رگرسیون (regression eval) می‌پرسد «چیزی را خراب کردیم؟». نمره‌اش باید نزدیک ۱۰۰٪ باشد و هر افتی یعنی باگ. وقتی ۱۴ fixture تو مدام پاس شوند، می‌شوند eval رگرسیون و بعد از هر تغییر پرامپت یا مدل دوباره اجرایشان می‌کنی.

پس همین ۱۴ فایل این هفته eval توانایی‌اند و تا ماژول ۵ eval رگرسیون، و اشکالی هم ندارد. فقط تسکی را منتقل کن که واقعاً پایدار پاس می‌شود. پایداری را هم با pass^k می‌سنجی که در مفهوم ۳ می‌آید.

به نتیجه نمره بده و کاری به مسیر رسیدن به آن نداشته باش. اگر Codex قبل از جواب دادن cat زد یا شاهدی آورد که با مال تو فرق دارد، trial را رد نکن. فقط وقتی ردش کن که برچسب‌های جوابش غلط باشد.

#مفهوم ۲. نشت جواب و جلوگیری از آن

یک eval تا وقتی معنی دارد که مدل نتواند جواب‌ها را ببیند. fixtureهای sentinel از دو جا جواب را لو می‌دهند.

اسم فایل‌ها. از اسم k05-clean-deployment.yaml و j04-clean-execfile.js جواب معلوم است و k01-privileged.yaml هم برچسبش را لو می‌دهد. اگر reviewer مسیر فایل را ببیند، تا حدی فقط داری می‌سنجی که اسم فایل را خوب می‌خواند یا نه.

فایل برچسب‌ها. سندباکس پیش‌فرض codex exec فقط read-only است، یعنی جلوی نوشتن را می‌گیرد ولی خواندن آزاد است. ایجنتی که در ریپوی تو قرار است «fixtureها را review کند» به‌راحتی می‌تواند cat fixtures/labels.json بزند و آن‌وقت دیگر هیچ نمره‌ای معنی ندارد.

در مثال کامل، runner هر دو راه را می‌بندد:

  1. فقط محتوای fixture از stdin می‌رود و اسم فایل هیچ‌جای پرامپت نمی‌آید.
  2. خود Codex با -C و --skip-git-repo-check در یک پوشه موقت خالی اجرا می‌شود، پس ریپویی نیست که بخواهد در آن بگردد.
  3. با -c یک permission profile می‌دهی که کل پوشه sentinel را deny می‌کند: ":root"="read" و مسیر ریپو با "deny". همان سیستم پروفایل ماژول ۲ است، فقط برای همین یک دستور. کنارش --sandbox نده. طبق مستندات، فلگ سندباکس قدیمی و default_permissions با هم جمع نمی‌شوند و قدیمی برنده می‌شود.
  4. بعد از هر trial، اسکریپت trace را دنبال labels.json یا مسیر ریپو می‌گردد و اگر چیزی پیدا کند، آن ردیف را contaminated علامت می‌زند.

خلاصه اینکه هر چیزی که مدل بتواند بخواند عملاً جزو پرامپت است. fixtureها و برچسب‌ها فقط تا وقتی ground truth حساب می‌شوند که سیستمی که تستش می‌کنی آن‌ها را نبیند.

#مفهوم ۳. عددها: precision، recall، F1، هشدار روی فایل تمیز، pass@k و pass^k

نمره را جدا برای هر برچسب و روی همه trialها حساب می‌کنی. مثلاً برای host-pid:

  • مثبت درست (TP): این فایل باید این برچسب را می‌گرفت و Codex هم گزارشش کرد.
  • منفی کاذب (FN): باید می‌گرفت ولی گزارش نشد.
  • مثبت کاذب (FP): گزارش شد ولی این فایل نباید آن را می‌گرفت و در لیست مجاز مفهوم ۵ هم نیست.

از این سه، این معیارها درمی‌آیند:

معیار فرمول جواب چه سؤالی است
دقت (precision) TP / (TP + FP) وقتی می‌گوید host-pid، چند بار درست می‌گوید؟
بازیابی (recall) TP / (TP + FN) از مشکل‌های واقعی host-pid چندتا را پیدا کرد؟
معیار F1 2·P·R / (P + R) یک عدد که اگر هر کدام از این دو پایین باشد، پایین می‌آید
میکرو F1 معیار F1 روی جمع TP/FP/FN همه برچسب‌ها کیفیت کلی، با وزن تعداد تکرار هر برچسب
ماکرو F1 میانگین F1 برچسب‌ها کیفیت کلی، با وزن برابر برای هر برچسب

وقتی از هر برچسب فقط یک fixture داری، میکرو و ماکرو می‌توانند خیلی با هم فرق کنند. فرض کن reviewer روی ۹ برچسب ساده بی‌نقص است ولی host-pid و jwt-not-verified را اصلاً نمی‌بیند. میکرو F1 این reviewer خوب درمی‌آید و ماکرو F1 آن به‌وضوح بدتر.

برای reviewer امنیتی دو عدد دیگر هم مهم است:

  • هشدار روی فایل تمیز: تعداد trialهایی از ۴ فایل تمیز که برچسبی گزارش کرده‌اند. این عدد همان نویز است، و در pull requestهای واقعی بیشتر تغییرها تمیزند.
  • تلاش ناموفق: فراخوانی‌ای که کرش کرده، timeout خورده یا به سقف مصرف رسیده. scorer آن را مثل جوابی حساب می‌کند که چیزی پیدا نکرده، پس همه برچسب‌هایی که انتظارشان می‌رفت miss می‌شوند. اگر این trialها را کنار بگذاری، recall بی‌دلیل بالا می‌رود.

تفاوت pass@k و pass^k. یک trial وقتی پاس است که همه برچسب‌های مورد انتظار را پیدا کند و چیزی جز آن‌ها و برچسب‌های مجاز گزارش نکند. فرض کن برای هر fixture تعداد n trial داری که c تایش پاس شده:

  • احتمال اینکه دست‌کم یکی از k trial پاس شود را pass@k می‌گویند. جایی به کار می‌آید که یک موفقیت کافی است، مثلاً وقتی آدمی از بین چند پیش‌نویس بهترین را برمی‌دارد. scorer آن را با تخمین‌گر بی‌طرف مقاله Codex سال ۲۰۲۱ حساب می‌کند: 1 - C(n-c, k) / C(n, k).
  • احتمال اینکه هر k trial پاس شوند را pass^k می‌گویند. جایی به کار می‌آید که ثبات مهم است. reviewer خودکار در CI برای هر pull request یک بار اجرا می‌شود و باید هر بار درست بگوید. scorer آن را با C(c, k) / C(n, k) تخمین می‌زند.

هر چه k بزرگ‌تر شود، pass@k بالاتر می‌رود و pass^k پایین‌تر. خروجی زیر مال scorer روی یک اجرای مصنوعی است. همه fixtureها بی‌نقص‌اند جز k01 که از پنج اجرا فقط یک بار پاس شده:

text
 k   pass@k  pass^k
 1    0.94    0.94
 2    0.96    0.93
 3    0.97    0.93
 4    0.99    0.93
 5    1.00    0.93

از روی pass@5 همه‌چیز بی‌نقص به نظر می‌رسد، ولی pass^5 نشان می‌دهد از ۱۴ fixture یکی قابل اعتماد نیست. برای reviewer داخل CI، عدد دوم همان چیزی است که در عمل با آن سروکار داری.

#مفهوم ۴. تحلیل خطا: قبل از هر تغییری traceها را بخوان

در FAQ ارزیابی‌ای که Hamel Husain و Shreya Shankar نوشته‌اند و آخرین بار ۲۱ سپتامبر ۲۰۲۶ به‌روز شده، تحلیل خطا مهم‌ترین کار در eval است. روشش دو مرحله دارد و از پژوهش کیفی گرفته شده:

  1. کدگذاری باز (open coding). trialها را یکی‌یکی بخوان و برای هر کدام که غلط است، با کلمه‌های خودت یک یادداشت کوتاه بنویس: «روی k01 گفته runs-as-root چون runAsNonRoot ندارد»، «فقط hostNetwork را گفته، hostPID را ندیده»، «روی execFile با allowlist گفته command injection». فعلاً دسته‌بندی نکن. دست‌کم ۳۰ یادداشت اول را خودت بنویس و بعد اگر خواستی از مدل کمک بگیر. متخصص این حوزه تویی، و قرار است رفتار واقعی مدل را ببینی که لزوماً همان چیزی نیست که انتظارش را داری.
  2. کدگذاری محوری (axial coding). یادداشت‌ها را در چند دسته خطا گروه کن و ببین هر دسته چند مورد دارد. FAQ این را مهم‌ترین قدم می‌داند. به‌طور سرانگشتی، در این دوره پنج تا هشت دسته خوب است.

برای sentinel احتمالاً به دسته‌هایی شبیه این‌ها می‌رسی. دسته‌های خودت فرق خواهد کرد و باید هم فرق کند:

دسته خطا مثال اصلاح احتمالی
تعریف لغزنده گزارش runs-as-root روی k01، که runAsUser: 0 ندارد ولی runAsNonRoot هم ندارد تصمیم بگیر: تعریف را در پرامپت سفت‌تر کن یا آن را در allowed.json بگذار
مشکل دوم جا افتاده روی k07 فقط host-network آمده و host-pid نیامده در پرامپت: «همه مشکل‌های لیست را بگو، نه فقط جدی‌ترین را»
ایرادگیری روی فایل تمیز گزارش mutable-image-tag روی k07، چون 0.9.3 تگ است و تگ قابل جابه‌جایی است تصمیم با توست: تگ نسخه قابل قبول است؟ بنویسش
زیاده‌روی روی کد امن گزارش command-injection روی j04، که execFile را با allowlist صدا می‌زند یک مثال execFile امن در پرامپت، یا پذیرفتن هزینه‌اش
خطای قالب آرایه findings خالی است ولی مشکل در other_findings توضیح داده شده در پرامپت: «اگر یکی از idهای لیست می‌خورد، همان را به کار ببر»

هر بار فقط یک چیز را عوض کن و بعد از هر تغییر دوباره اجرا کن، وگرنه نمی‌فهمی کدام تغییر کمک کرده. به نمره‌ها هم بسنده نکن و خود traceها را بخوان. در فایل‌های traces/*.jsonl همه دستورهایی که Codex اجرا کرده و همه پیام‌هایش هست. اگر در یک trace دیدی Codex خواسته پوشه بالایی را ls کند، این خودش یک یافته است.

وقتی در traceهای تازه دیگر به دسته جدیدی برنمی‌خوری، فعلاً کارت تمام است. FAQ اسم این را اشباع گذاشته. با ۷۰ trial زود به این نقطه می‌رسی، ولی در کار واقعی معمولاً حدود ۱۰۰ trace لازم است.

#مفهوم ۵. نمره‌دادن به «درست ولی برچسب‌نخورده»

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

راه چطور مناسب برای هزینه
سخت‌گیر هر چیزی خارج از labels.json یعنی FP ابزاری با کار محدود و قاعده‌های دقیق یافته درست را جریمه می‌کند و تو را به سمت ساکت کردن مدل با پرامپت می‌برد
لیست مجاز فایل eval/allowed.json برچسب‌هایی را می‌گوید که برای یک فایل درست‌اند ولی لازم نیستند. نه TP حساب می‌شوند نه FP اضافه‌های شناخته‌شده و پایدار، مثل runs-as-root روی k01 نباید بگذاری لیست بی‌حساب‌وکتاب بزرگ شود. برای هر ورودی دلیل بنویس
داور (judge) یک مدل دوم تصمیم می‌گیرد هر یافته برچسب‌نخورده واقعی است یا نه متن آزاد other_findings که هیچ لیستی نمی‌تواند همه‌اش را بشمارد یک مدل دیگر که باید چکش کنی و هزینه‌اش را بدهی

برای یافته‌هایی که id برچسب دارند از لیست مجاز استفاده کن و برای متن آزاد از judge. هیچ‌وقت برای بالا بردن نمره به labels.json دست نزن. اگر برچسبی واقعاً غلط است، در یک commit جدا و با دلیل درستش کن و همه اجراهای قبلی را دوباره نمره بده تا تاریخچه قابل مقایسه بماند.

داور هم یک classifier است و باید مثل هر classifier سنجیده شود. FAQ برای judgeهای LLM این قاعده‌ها را دارد و مثال کامل همه‌شان را رعایت می‌کند:

  • دودویی، پاس یا رد. نمره ۱ تا ۵ نده، چون با نمره «۳» نمی‌شود هیچ تصمیمی گرفت.
  • برای هر دسته خطا یک judge جدا. judge این ماژول فقط یک سؤال را جواب می‌دهد: «این یافته برچسب‌نخورده یک مشکل امنیتی واقعی در این فایل است؟»
  • اول استدلال، بعد حکم. در اسکیما، فیلد reasoning قبل از حکم می‌آید. راهنمای best practices ارزیابی OpenAI هم همین را توصیه می‌کند.
  • فقط چیزی را بده که لازم دارد، یعنی فایل و ادعا. برچسب‌ها و بقیه خروجی reviewer را به judge نشان نده.
  • هر دو عدد TPR و TNR را گزارش کن. درصد توافق به‌تنهایی گول‌زننده است. TPR یعنی وقتی تو می‌گویی «معتبر»، judge چند بار همین را می‌گوید. TNR همین است برای «نامعتبر». اگر ۱۸ مورد از ۲۰ مورد تو «نامعتبر» باشد، judgeی که همیشه «نامعتبر» بگوید ۹۰٪ با تو موافق است ولی TPR آن صفر است.

به پیشنهاد FAQ، قبل از اینکه در محیط واقعی به یک judge تکیه کنی، برای هر دسته خطا ۱۰۰ تا ۲۰۰ نمونه برچسب بزن. در این ماژول حدود ۲۰ تا برچسب می‌زنی. همین‌قدر کافی است که بفهمی judge تقریباً درست کار می‌کند و جاهایی را که با تو اختلاف دارد بخوانی. ولی برای اینکه چشم‌بسته به آن اعتماد کنی کافی نیست، و این صفحه عمداً این را صریح می‌گوید.

#مفهوم ۶. ابزار اجرای eval: اسکریپت خودت، promptfoo و آنچه OpenAI تعطیل کرد

همین eval را دو جور می‌شود اجرا کرد:

اسکریپت‌های خودت (run.ts و score.ts) ابزار promptfoo 0.125.1
چیست حدود ۲۵۰ خط TypeScript که مال خودت است ابزار اجرای eval متن‌باز با لایسنس MIT
اجرای Codex با دستور codex exec پرووایدر openai:codex-sdk (روی Codex TypeScript SDK)
احراز هویت همان ورود ChatGPT تو همان ورود ChatGPT تو، اگر کلید API در محیط نباشد
تکرار فلگ --runs 5 فلگ --repeat 5، و تست‌های ناپایدار را هم گزارش می‌کند
معیارها دقت، بازیابی و F1 هر برچسب، هشدار روی فایل تمیز، pass@k و pass^k نرخ پاس و نمره هر تست. برای معیار هر برچسب باید خودت کد بنویسی
نمایش ترمینال و jq دستور promptfoo view، یک رابط وب محلی برای مقایسه کنار هم
مناسب برای دقیقاً عددهایی که لازم داری مقایسه کنار هم، و red-team در ماژول ۷

پیشنهادم این است که هر دو را داشته باشی: عددهای امنیتی را از اسکریپت خودت بگیر و برای نمایش سراغ promptfoo برو.

پلتفرم Evals خود OpenAI دارد جمع می‌شود. این پلتفرم و graderهایش از ۳ ژوئن ۲۰۲۶ منسوخ شده‌اند. evalهای موجود از ۳۱ اکتبر ۲۰۲۶ فقط‌خواندنی‌اند و داشبورد و API در ۳۰ نوامبر ۲۰۲۶ خاموش می‌شوند. اوپن‌ای‌آی در صفحه deprecations، promptfoo را راه مهاجرت معرفی کرده و به یک راهنمای cookbook برای انتقال لینک داده. آموزش‌های قدیمی که با POST /v1/evals یا graderهای داشبورد کار می‌کنند همین ماه از کار می‌افتند، پس چیز تازه‌ای رویشان نساز.

مالک promptfoo کیست؟ اوپن‌ای‌آی ۹ مارس ۲۰۲۶ اعلام کرد که promptfoo را می‌خرد. خود promptfoo در پستش نوشته بسته شدن معامله «تابع شرایط معمول بسته شدن» است و promptfoo «متن‌باز می‌ماند». من هیچ تأیید رسمی پیدا نکردم که معامله بسته شده باشد. در ۱۱ نوامبر ۲۰۲۶ بسته npm هنوز لایسنس MIT داشت، با نسخه 0.125.1 در ریپوی promptfoo/promptfoo. اگر قرار است ابزار تیمی رویش بسازی، هر وقت خبر بسته شدن معامله آمد، لایسنس و ریپو را دوباره چک کن.

#مثال کامل: eval مربوط به review در sentinel

این فایل‌ها را به ریپو اضافه می‌کنی و هیچ‌کدام به fixtures/ دست نمی‌زنند:

text
sentinel/
  eval/
    review.schema.json   the shape Codex must answer in
    prompt.md            the reviewer instructions ({{KIND}} is filled in per file)
    run.ts               runs Codex over every fixture, N times
    score.ts             compares results with labels.json
    allowed.json         true-but-not-required labels (written after error analysis)
    judge.ts             binary judge for unlabelled findings
    agree.ts             judge versus your own labels
    human-labels.json    your verdicts on about 20 items
    promptfoo/           the same eval as a promptfoo config
  results/               add this to .gitignore

همه اسکریپت‌ها روی Node 22.18 و بالاتر اجرا می‌شوند. از نسخه 22.18.0 به بعد type stripping به‌طور پیش‌فرض روشن است و Node فایل .ts را مستقیم اجرا می‌کند. این قابلیت هنوز آزمایشی است، پس ممکن است هشدار ببینی. نسخه‌ات را با node --version چک کن. پوشه results/ را هم به .gitignore اضافه کن، چون traceها حجیم‌اند و قرار است زیاد اجرا کنی.

#قدم ۱. اسکیمای خروجی

وقتی به codex exec فلگ --output-schema <file> را بدهی، جواب نهایی باید از یک JSON Schema پیروی کند. مثال مستندات "additionalProperties": false دارد و همه propertyها را در required آورده. تو هم در همه سطح‌ها همین کار را بکن. مقدارهای enum فیلد label دقیقاً همان ۱۲ id داخل labels.json است، پس scorer هیچ‌وقت مجبور نیست حدس بزند «Privileged pod» یعنی چه. فیلد other_findings هم جایی است برای مشکل‌هایی که در لیست نیستند. اگر نباشد، مشکل واقعی‌ای که در enum نیامده یا به زور زیر برچسب غلط می‌رود یا کلاً گم می‌شود.

json
{
  "type": "object",
  "properties": {
    "findings": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "label": {
            "type": "string",
            "enum": [
              "privileged-container", "hostpath-mount", "secret-in-plain-env",
              "mutable-image-tag", "runs-as-root", "rbac-wildcard",
              "host-network", "host-pid", "command-injection",
              "code-injection-eval", "ssrf", "jwt-not-verified"
            ]
          },
          "evidence": { "type": "string" }
        },
        "required": ["label", "evidence"],
        "additionalProperties": false
      }
    },
    "other_findings": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "title": { "type": "string" },
          "evidence": { "type": "string" }
        },
        "required": ["title", "evidence"],
        "additionalProperties": false
      }
    }
  },
  "required": ["findings", "other_findings"],
  "additionalProperties": false
}

#قدم ۲. پرامپت reviewer

تعریف‌های این پرامپت در واقع مشخصات eval تو هستند. «تگ ایمیج قابل تغییر» اگر تعریف نشود می‌تواند هر چیزی باشد، از latest تا «هر چیزی غیر از digest»، و آن‌وقت تحلیل خطایت می‌شود بحث با خودت. پس دقیق بنویس منظورت چیست.

markdown
You are a security reviewer. The <stdin> block below is one {{KIND}}.
Review only that text. Do not run commands and do not read other files.

Report each problem from this list that is clearly present, using its exact id:

- privileged-container: a container sets securityContext.privileged: true
- hostpath-mount: a volume uses hostPath
- secret-in-plain-env: a secret value is written as a literal env value (not valueFrom)
- mutable-image-tag: an image has no tag or uses the tag "latest"
- runs-as-root: a pod or container explicitly sets runAsUser: 0
- rbac-wildcard: an RBAC rule uses "*" for apiGroups, resources or verbs
- host-network: the pod sets hostNetwork: true
- host-pid: the pod sets hostPID: true
- command-injection: user input reaches a shell command (exec, execSync, spawn with shell)
- code-injection-eval: user input reaches eval, new Function or vm
- ssrf: user input controls the URL of a server-side request
- jwt-not-verified: a JWT is decoded and trusted without signature verification

Put each one in "findings" with a short quote from the text as evidence.
Put any other real security problem in "other_findings" with a short title.
If nothing from the list is present, return an empty "findings" array. Do not guess.

#قدم ۳. اسکریپت runner

برای هر fixture در هر اجرا یک فراخوانی داری، یعنی ۱۴ × ۵ = ۷۰ فراخوانی با مدل پیش‌فرض gpt-6-luna. در ۱۱ نوامبر ۲۰۲۶ سقف پلن Plus در هر پنج ساعت ۳۵۰ تا ۳۰۰۰ پیام برای Luna و ۱۵ تا ۱۶۰ پیام برای GPT-6.1 Sol بود. پس پنج اجرا را روی Luna بزن و Sol را اول با --runs 1 امتحان کن. اسکریپت عمداً فراخوانی‌ها را یکی‌یکی می‌زند و بعد از سه شکست پشت سر هم می‌ایستد. این معمولاً یعنی به سقف خورده‌ای. برای ادامه، همان دستور را با همان پوشه --out دوباره بزن. ردیف‌های موفق رد می‌شوند و ناموفق‌ها دوباره امتحان می‌شوند.

ts
// eval/run.ts — run Codex as the reviewer over every fixture, N times.
// Usage: node eval/run.ts --runs 5 [--model gpt-6-luna] [--out results/<dir>] [--only k8s/]
// Needs Node 22.18+ (runs .ts directly) and a signed-in `codex` on PATH.
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parseArgs } from "node:util";

const { values: opt } = parseArgs({
  options: {
    runs: { type: "string", default: "5" },
    model: { type: "string", default: "gpt-6-luna" },
    out: { type: "string" },
    prompt: { type: "string", default: "eval/prompt.md" },
    only: { type: "string" },
  },
});

const ROOT = process.cwd(); // run from the sentinel repo root
const CODEX = process.env.CODEX_BIN ?? "codex";
const labels: Record<string, string[]> = JSON.parse(
  fs.readFileSync(path.join(ROOT, "fixtures/labels.json"), "utf8"),
);
const files = Object.keys(labels).filter((f) => !opt.only || f.includes(opt.only));
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-");
const outDir = path.resolve(opt.out ?? `results/${stamp}-${opt.model}`);
const traceDir = path.join(outDir, "traces");
fs.mkdirSync(traceDir, { recursive: true });
const resultsPath = path.join(outDir, "results.jsonl");
const schemaPath = path.join(ROOT, "eval/review.schema.json");
const template = fs.readFileSync(path.resolve(opt.prompt), "utf8");

// Resume: skip (run, file) pairs that already have a good row. Failed rows are retried.
const done = new Set<string>();
if (fs.existsSync(resultsPath)) {
  for (const line of fs.readFileSync(resultsPath, "utf8").split("\n")) {
    if (!line.trim()) continue;
    const r = JSON.parse(line);
    if (r.ok) done.add(`${r.run}|${r.file}`);
  }
}

// The reviewer works in an empty folder and cannot read the repo at all,
// so it never sees file names like "k05-clean-deployment" or labels.json.
const blank = fs.mkdtempSync(path.join(os.tmpdir(), "sentinel-review-"));
const permissionArgs = [
  "-c", 'default_permissions="reviewer"',
  "-c", `permissions.reviewer.filesystem={":root"="read","${ROOT}"="deny"}`,
];

type Finding = { label: string; evidence: string };
type Row = {
  run: number; file: string; model: string; ok: boolean; ms: number;
  findings: Finding[]; other_findings: { title: string; evidence: string }[];
  usage: unknown; error?: string; contaminated?: boolean;
};

function reviewOnce(run: number, file: string): Promise<Row> {
  const kind = file.startsWith("k8s/") ? "Kubernetes manifest" : "JavaScript (Node.js) module";
  const tag = `run${run}-${file.replace(/[/.]/g, "_")}`;
  const lastPath = path.join(traceDir, `${tag}.last.json`);
  const tracePath = path.join(traceDir, `${tag}.jsonl`);
  const args = [
    "exec", "--json", "--ephemeral", "--skip-git-repo-check",
    "-C", blank, "-m", opt.model!, ...permissionArgs,
    "--output-schema", schemaPath, "-o", lastPath,
    template.replaceAll("{{KIND}}", kind),
  ];
  const started = Date.now();
  return new Promise((resolve) => {
    const child = spawn(CODEX, args, { stdio: ["pipe", "pipe", "pipe"] });
    const trace = fs.createWriteStream(tracePath);
    let usage: unknown = null;
    let pending = "";
    let stderr = "";
    child.stdout.on("data", (chunk: Buffer) => {
      trace.write(chunk);
      pending += chunk.toString();
      let nl: number;
      while ((nl = pending.indexOf("\n")) >= 0) {
        const line = pending.slice(0, nl);
        pending = pending.slice(nl + 1);
        try {
          const ev = JSON.parse(line);
          if (ev.type === "turn.completed") usage = ev.usage;
        } catch { /* not JSON: ignore */ }
      }
    });
    child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
    child.on("error", (err) => (stderr += String(err)));
    // The fixture goes in on stdin and becomes a <stdin> block after the prompt.
    child.stdin.end(fs.readFileSync(path.join(ROOT, "fixtures", file)));
    child.on("close", (code) => {
      trace.end();
      const base = { run, file, model: opt.model!, ms: Date.now() - started, usage };
      try {
        if (code !== 0) throw new Error(`codex exited ${code}: ${stderr.trim().split("\n").slice(-2).join(" | ")}`);
        const out = JSON.parse(fs.readFileSync(lastPath, "utf8"));
        const traceText = fs.readFileSync(tracePath, "utf8");
        resolve({
          ...base, ok: true, findings: out.findings ?? [], other_findings: out.other_findings ?? [],
          contaminated: traceText.includes("labels.json") || traceText.includes(ROOT),
        });
      } catch (err) {
        resolve({ ...base, ok: false, error: (err as Error).message, findings: [], other_findings: [] });
      }
    });
  });
}

let failuresInARow = 0;
for (let run = 1; run <= Number(opt.runs); run++) {
  for (const file of files) {
    if (done.has(`${run}|${file}`)) continue;
    const row = await reviewOnce(run, file);
    fs.appendFileSync(resultsPath, JSON.stringify(row) + "\n");
    const what = row.ok ? row.findings.map((f) => f.label).join(", ") || "(none)" : row.error;
    console.log(`${row.ok ? "ok " : "ERR"} run ${run} ${file}: ${what}${row.contaminated ? "  [CONTAMINATED]" : ""}`);
    failuresInARow = row.ok ? 0 : failuresInARow + 1;
    if (failuresInARow >= 3) {
      console.error(`3 failures in a row, stopping. Probably a usage limit. Re-run with --out ${outDir} later to resume.`);
      process.exit(1);
    }
  }
}
console.log(`results: ${resultsPath}`);

این فلگ‌ها را اسکریپت به codex exec می‌دهد، هر کدام به یک دلیل. همه را با codex exec --help در CLI 0.167.1 چک کرده‌ام:

فلگ چرا
--json همه رویدادها را به شکل JSONL روی stdout می‌فرستد. این همان transcript است که در تحلیل خطا می‌خوانی. رویداد turn.completed مصرف توکن (usage) را دارد
--ephemeral فایل session نمی‌نویسد. هفتاد session دورریختنی فهرست codex resume را شلوغ می‌کرد
--skip-git-repo-check, -C <blank> اجرا در یک پوشه خالی که ریپوی Git نیست
-c default_permissions=..., -c permissions.reviewer.filesystem=... خواندن همه‌جا جز ریپوی تو، فقط برای همین دستور
--output-schema جواب نهایی را مجبور می‌کند طبق اسکیما باشد
-o پیام نهایی را جدا از جریان رویدادها در یک فایل می‌نویسد

اجرایش کن:

bash
node eval/run.ts --runs 5            # about 70 calls; prints one line per trial

#قدم ۴. اسکریپت scorer

در scorer خبری از مدل نیست و به شبکه هم کاری ندارد. فقط یک یا چند فایل results.jsonl را می‌خواند. اگر برای یک اجرا و یک فایل دو ردیف باشد، ردیف آخر حساب می‌شود، پس trialی که دوباره زده شده جای شکست قبلی را می‌گیرد. حتماً --runs 5 را بده تا ردیف‌هایی که گم شده‌اند بی‌صدا حذف نشوند و trial ناموفق حساب شوند.

ts
// eval/score.ts — score reviewer results against fixtures/labels.json.
// Usage: node eval/score.ts results/<dir>/results.jsonl [--runs 5] [--allowed eval/allowed.json] [--json summary.json]
import fs from "node:fs";
import { parseArgs } from "node:util";

const { values: opt, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    labels: { type: "string", default: "fixtures/labels.json" },
    allowed: { type: "string" }, // file -> labels that are true but not required: neither TP nor FP
    runs: { type: "string" },    // expected runs; missing rows count as failed trials
    json: { type: "string" },    // write a machine-readable summary here
  },
});
if (positionals.length === 0) {
  console.error("usage: node eval/score.ts <results.jsonl> [more.jsonl] [--runs N] [--allowed file] [--json out]");
  process.exit(2);
}

type Row = { run: number; file: string; ok: boolean; findings?: { label: string }[]; other_findings?: unknown[] };
const labels: Record<string, string[]> = JSON.parse(fs.readFileSync(opt.labels!, "utf8"));
const allowed: Record<string, string[]> = opt.allowed ? JSON.parse(fs.readFileSync(opt.allowed, "utf8")) : {};

// Last row wins for each (run, file): a retried run replaces its failed attempt.
const rows = new Map<string, Row>();
for (const p of positionals) {
  for (const line of fs.readFileSync(p, "utf8").split("\n")) {
    if (!line.trim()) continue;
    const r: Row = JSON.parse(line);
    if (!(r.file in labels)) continue;
    rows.set(`${r.run}|${r.file}`, r);
  }
}
const seenRuns = [...rows.values()].map((r) => r.run);
const nRuns = opt.runs ? Number(opt.runs) : Math.max(0, ...seenRuns);
const files = Object.keys(labels);
const labelIds = [...new Set(Object.values(labels).flat())].sort();

const counts: Record<string, { tp: number; fp: number; fn: number }> = {};
const bump = (id: string, k: "tp" | "fp" | "fn") => ((counts[id] ??= { tp: 0, fp: 0, fn: 0 })[k] += 1);
for (const id of labelIds) counts[id] = { tp: 0, fp: 0, fn: 0 };

let failed = 0, missing = 0, cleanTrials = 0, cleanFlagged = 0, otherFindings = 0;
const passes: Record<string, number> = Object.fromEntries(files.map((f) => [f, 0]));

for (let run = 1; run <= nRuns; run++) {
  for (const file of files) {
    const row = rows.get(`${run}|${file}`);
    if (!row) missing++;
    else if (!row.ok) failed++;
    // A missing or failed trial predicts nothing: every expected label becomes a miss.
    const predicted = new Set(row?.ok ? (row.findings ?? []).map((f) => f.label) : []);
    const expected = new Set(labels[file]);
    const extra = new Set(allowed[file] ?? []);
    otherFindings += row?.ok ? (row.other_findings ?? []).length : 0;

    let fileFp = 0, fileFn = 0;
    for (const id of expected) {
      if (predicted.has(id)) bump(id, "tp");
      else { bump(id, "fn"); fileFn++; }
    }
    for (const id of predicted) {
      if (!expected.has(id) && !extra.has(id)) { bump(id, "fp"); fileFp++; }
    }
    // A trial passes when it finds every expected label and nothing outside expected + allowed.
    const ok = row?.ok === true && fileFn === 0 && fileFp === 0;
    if (ok) passes[file]++;
    if (expected.size === 0) {
      cleanTrials++;
      if (fileFp > 0) cleanFlagged++;
    }
  }
}

const ratio = (a: number, b: number) => (b === 0 ? null : a / b);
const f1 = (p: number | null, r: number | null) => (p === null || r === null || p + r === 0 ? null : (2 * p * r) / (p + r));
const fmt = (x: number | null) => (x === null ? "  -  " : x.toFixed(2).padStart(5));

// Unbiased estimators from n trials with c passes (Chen et al. 2021 for pass@k).
function choose(n: number, k: number): number {
  if (k < 0 || k > n) return 0;
  let r = 1;
  for (let i = 1; i <= k; i++) r = (r * (n - k + i)) / i;
  return r;
}
const passAtK = (n: number, c: number, k: number) => 1 - choose(n - c, k) / choose(n, k); // at least one of k passes
const passHatK = (n: number, c: number, k: number) => choose(c, k) / choose(n, k);         // all k pass

const perLabel = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([id, c]) => {
  const precision = ratio(c.tp, c.tp + c.fp);
  const recall = ratio(c.tp, c.tp + c.fn);
  return { id, ...c, precision, recall, f1: f1(precision, recall) };
});
const tot = perLabel.reduce((a, l) => ({ tp: a.tp + l.tp, fp: a.fp + l.fp, fn: a.fn + l.fn }), { tp: 0, fp: 0, fn: 0 });
const microP = ratio(tot.tp, tot.tp + tot.fp);
const microR = ratio(tot.tp, tot.tp + tot.fn);
const scored = perLabel.filter((l) => l.f1 !== null);
const macroF1 = scored.length ? scored.reduce((a, l) => a + (l.f1 as number), 0) / scored.length : null;
const ks = Array.from({ length: nRuns }, (_, i) => i + 1);
const passK = ks.map((k) => ({
  k,
  passAtK: files.reduce((a, f) => a + passAtK(nRuns, passes[f], k), 0) / files.length,
  passHatK: files.reduce((a, f) => a + passHatK(nRuns, passes[f], k), 0) / files.length,
}));

console.log(`runs ${nRuns} · fixtures ${files.length} · trials ${nRuns * files.length} · failed ${failed} · missing ${missing}\n`);
console.log("label                   TP  FP  FN  prec   rec    F1");
for (const l of perLabel) {
  console.log(`${l.id.padEnd(22)} ${String(l.tp).padStart(3)} ${String(l.fp).padStart(3)} ${String(l.fn).padStart(3)}  ${fmt(l.precision)} ${fmt(l.recall)} ${fmt(l.f1)}`);
}
console.log(`${"micro".padEnd(22)} ${String(tot.tp).padStart(3)} ${String(tot.fp).padStart(3)} ${String(tot.fn).padStart(3)}  ${fmt(microP)} ${fmt(microR)} ${fmt(f1(microP, microR))}`);
console.log(`macro F1 ${fmt(macroF1)}`);
console.log(`\nclean files flagged: ${cleanFlagged} of ${cleanTrials} trials · other_findings (unlabelled): ${otherFindings}`);
console.log("\nper fixture (passes / runs):");
for (const f of files) console.log(`  ${String(passes[f]).padStart(2)}/${nRuns}  ${f}`);
console.log("\n k   pass@k  pass^k");
for (const p of passK) console.log(`${String(p.k).padStart(2)}   ${fmt(p.passAtK)}   ${fmt(p.passHatK)}`);

if (opt.json) {
  const summary = {
    runs: nRuns, failed, missing, cleanFlagged, cleanTrials, otherFindings,
    micro: { ...tot, precision: microP, recall: microR, f1: f1(microP, microR) },
    macroF1, perLabel, passes, passK,
  };
  fs.writeFileSync(opt.json, JSON.stringify(summary, null, 2));
}
bash
node eval/score.ts results/<your-dir>/results.jsonl --runs 5 --json results/<your-dir>/summary.json

این scorer را با Node 22.22 روی نتیجه‌های مصنوعی که جوابشان را از قبل می‌دانستم تست کردم. برای اجرای بی‌نقص، میکرو F1 برابر 1.00 و pass^5 هم برابر 1.00 درمی‌آید. reviewerی که هر ۱۲ برچسب را روی همه فایل‌ها گزارش کند، recall برابر 1.00 و precision برابر 0.07 می‌گیرد و روی هر ۲۰ trial تمیز هم هشدار می‌دهد. reviewerی که هیچ چیزی گزارش نکند precision ندارد و scorer جایش - چاپ می‌کند، چون هیچ پیش‌بینی‌ای در کار نیست. pass@1 همین reviewer 0.29 است، یعنی همان ۴ فایل تمیز از ۱۴. یک trial گم‌شده و یک trial ناموفق هم هر کدام یک miss حساب شدند. اگر عددهای خودت عجیب درآمد، اول با همین حالت‌ها مقایسه‌شان کن.

#قدم ۵. تحلیل خطا و لیست مجاز

حالا traceهای trialهای ناموفق را باز کن. بخش «per fixture» در خروجی scorer نشانت می‌دهد از کجا شروع کنی. کدهای بازت را در کادر یادداشت بخش تمرین بنویس. بعد گروهشان کن و برای هر دسته تصمیم بگیر. هر تصمیم آخرش یکی از این سه تغییر می‌شود: ویرایش پرامپت، یک ورودی تازه در allowed.json، یا اصلاح یک برچسب، که باید کم پیش بیاید و در commit جدا باشد.

بعد از دور اول، allowed.json معمولاً چیزی شبیه این است. برای هر ورودی باید دلیلی داشته باشی که بتوانی جلوی یک همکار از آن دفاع کنی:

json
{
  "k8s/k01-privileged.yaml": ["runs-as-root"],
  "k8s/k02-hostpath.yaml": ["runs-as-root"],
  "k8s/k03-secret-literal.yaml": ["runs-as-root"],
  "k8s/k07-hostnetwork.yaml": ["runs-as-root"]
}

این podها runAsNonRoot ندارند، پس اینکه با root اجرا شوند یا نه به ایمیج بستگی دارد. اگر reviewer گزارشش کند حق دارد، ولی نمی‌شود این را از او انتظار داشت. حالا همان نتیجه‌ها را با --allowed eval/allowed.json دوباره نمره بده. مدل همان است و فقط روش نمره‌دادنت عوض شده، پس دقیقاً می‌بینی این تصمیم چقدر نمره را جابه‌جا کرد.

#قدم ۶. داور، و سنجیدن داور

اول یافته‌های برچسب‌نخورده را بدون judge فهرست کن و حدود ۲۰ تا از آن‌ها را خودت برچسب بزن. حواست باشد این کار قبل از دیدن هر خروجی‌ای از judge باشد:

bash
node eval/judge.ts results/<your-dir>/results.jsonl --items-only   # writes items.jsonl

حکم‌هایت را در eval/human-labels.json بنویس و id هر مورد را کلید بگذار: { "e828f924": "valid", "54396a9a": "invalid", ... }. موردها را طوری بردار که هر دو نوع در آن‌ها باشد. اگر همه «نامعتبر» باشند، TPR را نمی‌شود اندازه گرفت.

ts
// eval/judge.ts — a binary LLM judge for one failure mode: "is this unlabelled finding real?"
// Usage: node eval/judge.ts results/<dir>/results.jsonl --items-only   (list the items, label them yourself first)
//        node eval/judge.ts results/<dir>/results.jsonl [--model gpt-6.1-sol]
// Writes results/<dir>/items.jsonl and judge.jsonl. Re-running skips items already judged.
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parseArgs } from "node:util";

const { values: opt, positionals } = parseArgs({
  allowPositionals: true,
  options: {
    model: { type: "string", default: "gpt-6.1-sol" },
    "items-only": { type: "boolean", default: false },
  },
});
const ROOT = process.cwd();
const CODEX = process.env.CODEX_BIN ?? "codex";
const resultsPath = path.resolve(positionals[0]);
const outPath = path.join(path.dirname(resultsPath), "judge.jsonl");
const labels: Record<string, string[]> = JSON.parse(fs.readFileSync("fixtures/labels.json", "utf8"));

// 1. Collect the items: every finding the labels do not account for, deduplicated per file.
type Item = { id: string; file: string; claim: string; evidence: string };
const items = new Map<string, Item>();
for (const line of fs.readFileSync(resultsPath, "utf8").split("\n")) {
  if (!line.trim()) continue;
  const r = JSON.parse(line);
  if (!r.ok) continue;
  const extra = [
    ...r.findings.filter((f: any) => !labels[r.file].includes(f.label)).map((f: any) => ({ claim: f.label, evidence: f.evidence })),
    ...r.other_findings.map((f: any) => ({ claim: f.title, evidence: f.evidence })),
  ];
  for (const e of extra) {
    const key = `${r.file}|${e.claim.toLowerCase().trim()}`;
    const id = crypto.createHash("sha256").update(key).digest("hex").slice(0, 8);
    if (!items.has(id)) items.set(id, { id, file: r.file, ...e });
  }
}

fs.writeFileSync(path.join(path.dirname(resultsPath), "items.jsonl"), [...items.values()].map((i) => JSON.stringify(i)).join("\n") + "\n");
console.log(`${items.size} unlabelled findings -> items.jsonl`);
if (opt["items-only"]) process.exit(0);

// 2. Judge each one. The judge sees only the file and the claim: not the labels, not the reviewer's other output.
const judged = new Set(
  fs.existsSync(outPath) ? fs.readFileSync(outPath, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l).id) : [],
);
const schemaPath = path.join(os.tmpdir(), "sentinel-judge.schema.json");
fs.writeFileSync(schemaPath, JSON.stringify({
  type: "object",
  properties: {
    reasoning: { type: "string" },
    verdict: { type: "string", enum: ["valid", "invalid"] },
  },
  required: ["reasoning", "verdict"],
  additionalProperties: false,
}));
const blank = fs.mkdtempSync(path.join(os.tmpdir(), "sentinel-judge-"));

for (const item of items.values()) {
  if (judged.has(item.id)) continue;
  const prompt = [
    "You check one claim made by a security reviewer about the file in the <stdin> block.",
    `Claim: ${item.claim}`,
    `Reviewer's evidence: ${item.evidence}`,
    "",
    'Answer "valid" only if the claim describes a real, exploitable or policy-relevant security problem',
    "that is visible in this file as written. Style issues, missing best practices with no security impact,",
    'and problems that depend on code not shown are "invalid". Explain your reasoning first, in two sentences.',
  ].join("\n");
  const lastPath = path.join(blank, `${item.id}.json`);
  const r = spawnSync(CODEX, [
    "exec", "--ephemeral", "--skip-git-repo-check", "-C", blank, "-m", opt.model!,
    "-c", 'default_permissions="judge"',
    "-c", `permissions.judge.filesystem={":root"="read","${ROOT}"="deny"}`,
    "--output-schema", schemaPath, "-o", lastPath, prompt,
  ], { input: fs.readFileSync(path.join(ROOT, "fixtures", item.file)), encoding: "utf8" });
  if (r.status !== 0) {
    console.error(`judge failed on ${item.id}: ${r.stderr.trim().split("\n").pop()}`);
    process.exit(1);
  }
  const out = JSON.parse(fs.readFileSync(lastPath, "utf8"));
  fs.appendFileSync(outPath, JSON.stringify({ ...item, verdict: out.verdict, reasoning: out.reasoning }) + "\n");
  console.log(`${out.verdict.padEnd(7)} ${item.id} ${item.file}: ${item.claim}`);
}
console.log(`judge: ${outPath}`);

مدل پیش‌فرض judge، GPT-6.1 Sol است. judge باید دست‌کم به اندازه سیستمی که چکش می‌کند توانا باشد، تازه موردهایش هم از reviewer کمتر است. بعد خروجی‌اش را با برچسب‌های خودت مقایسه کن:

ts
// eval/agree.ts — how well does the judge agree with you?
// Usage: node eval/agree.ts results/<dir>/judge.jsonl eval/human-labels.json
// human-labels.json: { "<item id>": "valid" | "invalid", ... } — about 20 items you labelled yourself, before reading the judge.
import fs from "node:fs";

const [judgePath, humanPath] = process.argv.slice(2);
if (!judgePath || !humanPath) {
  console.error("usage: node eval/agree.ts <judge.jsonl> <human-labels.json>");
  process.exit(2);
}
const human: Record<string, "valid" | "invalid"> = JSON.parse(fs.readFileSync(humanPath, "utf8"));
const judge = new Map<string, { verdict: string; file: string; claim: string }>();
for (const line of fs.readFileSync(judgePath, "utf8").split("\n")) {
  if (line.trim()) { const r = JSON.parse(line); judge.set(r.id, r); }
}

let tp = 0, fn = 0, tn = 0, fp = 0;
const disagreements: string[] = [];
for (const [id, mine] of Object.entries(human)) {
  const j = judge.get(id);
  if (!j) { console.warn(`no judge verdict for ${id}`); continue; }
  if (mine === "valid" && j.verdict === "valid") tp++;
  else if (mine === "valid") { fn++; disagreements.push(`  you: valid,   judge: invalid  ${id} ${j.file}: ${j.claim}`); }
  else if (j.verdict === "invalid") tn++;
  else { fp++; disagreements.push(`  you: invalid, judge: valid    ${id} ${j.file}: ${j.claim}`); }
}
const n = tp + fn + tn + fp;
const pct = (a: number, b: number) => (b === 0 ? "  n/a" : `${((100 * a) / b).toFixed(0).padStart(4)}%`);
console.log(`items compared: ${n}  (you said valid: ${tp + fn}, invalid: ${tn + fp})`);
console.log(`TPR (judge says valid when you do):     ${pct(tp, tp + fn)}`);
console.log(`TNR (judge says invalid when you do):   ${pct(tn, tn + fp)}`);
console.log(`raw agreement:                          ${pct(tp + tn, n)}`);
if (disagreements.length) console.log(`\nread these:\n${disagreements.join("\n")}`);
bash
node eval/judge.ts results/<your-dir>/results.jsonl
node eval/agree.ts results/<your-dir>/judge.jsonl eval/human-labels.json

همه موردهای اختلاف را بخوان. گاهی judge اشتباه کرده و گاهی خودت، که آن‌وقت برچسبت را عوض می‌کنی. هر دو حالت چیزی یادت می‌دهد. اگر TPR یا TNR زیر حدود ۸۰٪ بود، پرامپت judge را عوض کن، مثل همیشه هر بار فقط یک تغییر، و agree.ts را دوباره بزن. فقط مراقب باش پرامپت را روی همان موردهایی تنظیم نکنی که با آن‌ها می‌سنجی‌اش. چند مورد را کنار بگذار و فقط برای چک آخر سراغشان برو.

#قدم ۷. همین eval در promptfoo

در promptfoo، پرووایدر openai:codex-sdk روی Codex TypeScript SDK کار می‌کند. طبق صفحه مستنداتش بازه SDK پشتیبانی‌شده >=0.156.1 <0.167.0 است، ولی در ۱۱ نوامبر ۲۰۲۶ جدیدترین SDK نسخه 0.167.1 بود که بیرون این بازه می‌افتد. پس آخرین نسخه داخل بازه، یعنی 0.166.0، را pin کن. این نسخه Codex CLI 0.166.0 خودش را همراه دارد که هنوز بالاتر از حداقل 0.149.0 ماژول ۰ است. خود promptfoo 0.125.1 هم Node 22.22.0 یا بالاتر می‌خواهد.

bash
npm install --save-dev promptfoo@0.125.1 @openai/codex-sdk@0.166.0
mkdir -p eval/promptfoo/blank && echo '{ "type": "commonjs" }' > eval/promptfoo/package.json

این package.json کوچک باعث می‌شود دو فایل .js پایین CommonJS باشند، هر چه در package.json ریشه نوشته باشد. پرامپت را یک تابع می‌سازد تا fixture به شکل متن خام خوانده شود. fixtureها را با varهای file:// لود نکن. promptfoo موقع لود کردن var بعضی نوع فایل‌ها را جور خاصی می‌خواند و مثلاً YAML و JSON را به شیء تبدیل می‌کند، در حالی که تو متن خام را لازم داری.

js
// eval/promptfoo/prompt.js — builds the same prompt as eval/run.ts, with the fixture inlined.
const fs = require("node:fs");
const path = require("node:path");
const REPO = path.resolve(__dirname, "../..");

module.exports = async function ({ vars }) {
  const kind = vars.file.startsWith("k8s/") ? "Kubernetes manifest" : "JavaScript (Node.js) module";
  const template = fs.readFileSync(path.join(REPO, "eval/prompt.md"), "utf8").replaceAll("{{KIND}}", kind);
  const body = fs.readFileSync(path.join(REPO, "fixtures", vars.file), "utf8");
  return `${template}\n<stdin>\n${body}</stdin>\n`;
};

قاعده پاس این assertion همان قاعده score.ts است و نمره‌ای که می‌دهد F1 همان فایل است:

js
// eval/promptfoo/assert.js — same pass rule as eval/score.ts: every expected label, nothing else.
const labels = require("../../fixtures/labels.json");

module.exports = (output, context) => {
  let parsed;
  try {
    parsed = typeof output === "string" ? JSON.parse(output) : output;
  } catch {
    return { pass: false, score: 0, reason: "output is not JSON" };
  }
  const expected = new Set(labels[context.vars.file]);
  const predicted = new Set((parsed.findings || []).map((f) => f.label));
  const missed = [...expected].filter((l) => !predicted.has(l));
  const extra = [...predicted].filter((l) => !expected.has(l));
  const tp = expected.size - missed.length;
  const score = expected.size === 0 && predicted.size === 0 ? 1 : (2 * tp) / (expected.size + predicted.size);
  return {
    pass: missed.length === 0 && extra.length === 0,
    score,
    reason: `missed: ${missed.join(", ") || "none"} · extra: ${extra.join(", ") || "none"}`,
  };
};

حالا خود کانفیگ. حواست به sandbox_mode: read-only باشد، چون این پرووایدر به‌طور پیش‌فرض workspace-write است. اسکیما را inline گذاشته‌ام چون مستندات پرووایدر همین شکل را نشان می‌دهند.

yaml
# eval/promptfoo/promptfooconfig.yaml — promptfoo 0.125.1, Codex on your ChatGPT sign-in
description: sentinel reviewer on the 14 fixtures

prompts:
  - file://prompt.js

providers:
  - id: openai:codex-sdk
    config:
      model: gpt-6-luna
      sandbox_mode: read-only          # the provider's default is workspace-write
      working_dir: ./blank             # an empty folder: no repo, no labels.json
      skip_git_repo_check: true
      output_schema:
        type: object
        properties:
          findings:
            type: array
            items:
              type: object
              properties:
                label:
                  type: string
                  enum: [privileged-container, hostpath-mount, secret-in-plain-env, mutable-image-tag,
                         runs-as-root, rbac-wildcard, host-network, host-pid, command-injection,
                         code-injection-eval, ssrf, jwt-not-verified]
                evidence: { type: string }
              required: [label, evidence]
              additionalProperties: false
          other_findings:
            type: array
            items:
              type: object
              properties:
                title: { type: string }
                evidence: { type: string }
              required: [title, evidence]
              additionalProperties: false
        required: [findings, other_findings]
        additionalProperties: false

defaultTest:
  assert:
    - type: is-json
    - type: javascript
      value: file://assert.js

tests:
  - vars: { file: k8s/k01-privileged.yaml }
  - vars: { file: k8s/k02-hostpath.yaml }
  - vars: { file: k8s/k03-secret-literal.yaml }
  - vars: { file: k8s/k04-latest-root.yaml }
  - vars: { file: k8s/k05-clean-deployment.yaml }
  - vars: { file: k8s/k06-rbac-wildcard.yaml }
  - vars: { file: k8s/k07-hostnetwork.yaml }
  - vars: { file: k8s/k08-clean-netpol.yaml }
  - vars: { file: js/j01-cmd-injection.js }
  - vars: { file: js/j02-eval.js }
  - vars: { file: js/j03-ssrf.js }
  - vars: { file: js/j04-clean-execfile.js }
  - vars: { file: js/j05-jwt-decode.js }
  - vars: { file: js/j06-clean-path.js }

evaluateOptions:
  maxConcurrency: 1

از داخل پوشه کانفیگ اجرایش کن. این‌طوری ./blank و مسیرهای file:// به همان جای درست اشاره می‌کنند، هر پوشه‌ای را هم که promptfoo مبنا بگیرد. مطمئن شو هیچ کلید API در شلت تعریف نشده، وگرنه پرووایدر به جای ورود ChatGPT از آن کلید استفاده می‌کند:

bash
cd eval/promptfoo
env -u OPENAI_API_KEY -u CODEX_API_KEY npx promptfoo validate -c promptfooconfig.yaml
env -u OPENAI_API_KEY -u CODEX_API_KEY npx promptfoo eval -c promptfooconfig.yaml --repeat 5 --no-cache -j 1 -o ../../results/promptfoo.json
npx promptfoo view

حتماً --no-cache را بگذار. بدون آن promptfoo ممکن است جواب قبلی را دوباره استفاده کند و پنج اجرا عملاً یکی شود. این کانفیگ promptfoo validate را بدون خطا گذراند و با یک پرووایدر جایگزین که مدلی صدا نمی‌زند از اول تا آخر اجرا شد و خلاصه «Repeat stability» را هم داد. برای اجرا روی خود Codex باید وارد حسابت شده باشی.

این روش یک کمبود دارد. پرووایدر Codex را در ./blank با همان سندباکس read-only قدیمی اجرا می‌کند که فقط جلوی نوشتن را می‌گیرد و خواندن را آزاد می‌گذارد. runner خودت روی ریپو قانون deny می‌گذارد، ولی این کانفیگ چنین چیزی ندارد. پس اگر اینجا یک ۱۰۰٪ غیرمنتظره دیدی، شک کن که labels.json خوانده شده.

#تمرین

اولین اجرای نمره‌خورده (Luna، پنج اجرا): میکرو F1 · ماکرو F1 · فایل تمیز با هشدار · مقدار pass@1 · مقدار pass^5

بعد از تحلیل خطا و یک تغییر پرامپت: میکرو F1 · فایل تمیز با هشدار · مقدار pass^5

داور در برابر تو: مقدار TPR · مقدار TNR · تعداد موردها

ارزیابی تمام: عددهای scorer برای اجرای اول و اجرای بعد از یک تغییر بالا ثبت شده، pass^5 کنار pass@1 آمده، و از نمره دستی شروع سریع جلو زده‌ای، یعنی فایل‌های کاملاً درست بیشتر و هشدار روی فایل تمیز کمتر شده، یا نوشته‌ای چرا نشده.

تمام یعنی: کل eval را با دو دستور از نو اجرا می‌کنی، برای هر دسته خطا در طبقه‌بندی‌ات توضیح داری و می‌دانی تا کجا می‌شود به judge اعتماد کرد.

#خودت را بسنج

قبل از اینکه هر سؤال را باز کنی، جوابش را در ذهنت یا روی کاغذ بده.

۱. روی fixtureها reviewer تو recall صددرصد گرفته. چرا این برای گذاشتنش در CI کافی نیست؟

چون recall از نویز هیچ چیزی نشان نمی‌دهد. reviewerی که همه برچسب‌ها را روی همه فایل‌ها گزارش کند هم recall صددرصد می‌گیرد. باید precision هر برچسب و تعداد هشدار روی فایل تمیز را هم ببینی. برای CI به pass^k هم احتیاج داری، چون reviewer برای هر pull request فقط یک بار اجرا می‌شود و باید همان یک بار درست بگوید. اینکه از پنج بار یک بار درست بگوید کافی نیست.

۲. فرض کن pass@5 برابر `1.00` است و pass^5 برابر `0.71`. این یعنی چه و برای reviewer خودکار کدام را گزارش می‌کنی؟

یعنی هر fixture در پنج اجرا دست‌کم یک بار پاس شده، ولی تقریباً از هر ۱۰ fixture سه‌تا دست‌کم در یکی از پنج اجرا رد شده‌اند. پس reviewer ثبات ندارد. برای reviewerی که بی‌نظارت اجرا می‌شود pass^k و همین‌طور pass^1 را گزارش کن. pass@k جایی به کار می‌آید که آدمی از بین چند تلاش بهترین را برمی‌دارد.

۳. جواب fixtureهای اصلی از کدام دو راه به مدل نشت می‌کند و جلوی هر کدام را چطور می‌گیری؟

اول اسم فایل‌ها، مثل k05-clean-deployment.yaml، که برچسب را لو می‌دهند. راهش این است که فقط محتوا را از stdin بفرستی و مسیر را هیچ‌وقت. دوم fixtures/labels.json که خواندنی است، چون سندباکس read-only فقط جلوی نوشتن را می‌گیرد. راهش این است که در یک پوشه خالی اجرا کنی و ریپو را با permission profile ببندی. بعد هم traceها را دنبال labels.json بگرد.

۴. روی `k01`، که `runAsUser: 0` ندارد، Codex برچسب `runs-as-root` را گزارش کرده. این مثبت کاذب است؟

اگر سخت‌گیرانه نمره بدهی، بله. ولی این pod runAsNonRoot هم ندارد، پس شاید واقعاً با root اجرا شود. باید یکی را انتخاب کنی: یا تعریف را در پرامپت سفت‌تر کنی، یا این برچسب را برای همین فایل با دلیل مکتوب در allowed.json بگذاری. فقط labels.json را یواشکی عوض نکن که نمره بالا برود.

۵. داوری (judge) که ساختی در ۱۸ مورد از ۲۰ مورد با تو موافق است. چرا ممکن است باز هم به درد نخورد؟

چون اگر ۱۸ برچسب از ۲۰ برچسب تو «نامعتبر» باشد، judgeی که همیشه «نامعتبر» بگوید هم ۹۰٪ می‌گیرد. هر دو عدد TPR و TNR را جدا گزارش کن. judge خوب هر دو را بالا دارد، و نمونه‌ات هم باید آن‌قدر مورد «معتبر» داشته باشد که بشود TPR را اندازه گرفت.

۶. وقتی یک trial کرش می‌کند یا به سقف مصرف می‌خورد، چرا scorer آن را miss حساب می‌کند و کنارش نمی‌گذارد؟

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

۷. فرق eval توانایی و eval رگرسیون چیست و ۱۴ fixture تو کدام‌اند؟

نمره eval توانایی پایین شروع می‌شود و نشان می‌دهد روی کاری که سیستم هنوز پایدار انجامش نمی‌دهد چقدر جلو رفته‌ای. eval رگرسیون باید نزدیک ۱۰۰٪ باشد و کارش گرفتن خرابی است. تا وقتی داری پرامپت را تنظیم می‌کنی، fixtureها eval توانایی‌اند. وقتی pass^5 به 1.00 نزدیک شد، می‌شوند مجموعه رگرسیون تو و موردهای سخت تازه، یعنی pull requestهای کاشته‌شده ماژول ۴، نقش eval توانایی را می‌گیرند.

۸. یک پست وبلاگ گفته روی داشبورد OpenAI Evals چند grader بسازی. چه کار می‌کنی؟

نمی‌سازم. پلتفرم Evals از ۳ ژوئن ۲۰۲۶ منسوخ شده، از ۳۱ اکتبر ۲۰۲۶ فقط‌خواندنی است و داشبورد و API آن ۳۰ نوامبر ۲۰۲۶ خاموش می‌شوند. خود اوپن‌ای‌آی هم promptfoo را راه مهاجرت معرفی کرده. به جایش promptfoo یا اسکریپت‌های خودت را به کار ببر.

#چه یاد گرفتم

#کجا به کار بردم

#مرور

#منابع

به همین ترتیب بخوانشان. همه را ۱۱ نوامبر ۲۰۲۶ چک کرده‌ام.

  1. نوشته Hamel Husain و Shreya Shankar با عنوان «AI Evals: Everything You Need to Know» (FAQ، به‌روز شده در ۲۱ سپتامبر ۲۰۲۶): تحلیل خطا، کدگذاری باز و محوری، judge دودویی، TPR و TNR. بخش‌های تحلیل خطا و LLM judge را کامل بخوان. https://hamel.dev/blog/posts/evals-faq/
  2. مقاله Anthropic با عنوان «Demystifying evals for AI agents» (۹ ژانویه ۲۰۲۶): واژه‌های task و trial و grader، فرق توانایی و رگرسیون، pass@k در برابر pass^k. https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents
  3. مستندات Codex، بخش Non-interactive mode: دستور codex exec، فلگ‌های --output-schema و --json و -o، و سندباکس read-only پیش‌فرض. https://learn.chatgpt.com/docs/non-interactive-mode.md
  4. مستندات Codex، بخش Permissions: نحو پروفایل، deny، و اینکه چرا --sandbox روی default_permissions غلبه می‌کند. https://learn.chatgpt.com/docs/permissions.md
  5. راهنمای OpenAI با عنوان Evaluation best practices: توسعه مبتنی بر eval، judge پاس/رد، استدلال قبل از نمره، کالیبره کردن judge با برچسب آدم. https://developers.openai.com/api/docs/guides/evaluation-best-practices
  6. مقاله Chen و همکاران با عنوان «Evaluating Large Language Models Trained on Code» (۲۰۲۱): تخمین‌گر بی‌طرف pass@k که scorer به کار می‌برد. https://arxiv.org/abs/2107.03374
  7. مستندات promptfoo، پرووایدر OpenAI Codex SDK: id پرووایدر، کلیدهای کانفیگ، بازه SDK پشتیبانی‌شده، ورود با ChatGPT. https://www.promptfoo.dev/docs/providers/openai-codex-sdk/
  8. مستندات promptfoo، مرجع کانفیگ: کلیدهای evaluateOptions و defaultTest و شکل GradingResult. https://www.promptfoo.dev/docs/configuration/reference/
  9. مستندات promptfoo، assertionهای JavaScript: امضای تابع و مقدارهای برگشتی مجاز. https://www.promptfoo.dev/docs/configuration/expected-outputs/javascript/
  10. صفحه Deprecations اوپن‌ای‌آی: تاریخ‌های پلتفرم Evals و اشاره به promptfoo. https://developers.openai.com/api/docs/deprecations
  11. راهنمای Cookbook اوپن‌ای‌آی برای انتقال از OpenAI Evals به promptfoo: فقط اگر eval قدیمی برای انتقال داری. https://developers.openai.com/cookbook/examples/evaluation/moving-from-openai-evals-to-promptfoo
  12. پست promptfoo با عنوان «Promptfoo is joining OpenAI» (۹ مارس ۲۰۲۶): اعلام خرید و شرایط بسته شدن معامله. https://www.promptfoo.dev/blog/promptfoo-joining-openai
  13. مستندات Codex، بخش Pricing: سقف پلن برای هر مدل. قبل از هر دسته پنج‌اجرایی دوباره چکش کن. https://learn.chatgpt.com/docs/pricing.md

آخرین بررسی: ۱۱ نوامبر ۲۰۲۶، با Codex CLI 0.167.1 (codex exec --help و codex doctor)، promptfoo 0.125.1 (promptfoo validate) و @openai/codex-sdk 0.166.0. اسکریپت‌های scorer و judge و agree و runner روی داده مصنوعی و یک باینری جایگزین codex اجرا شدند. برای اجرا روی Codex واقعی باید وارد حسابت شده باشی.