engineering

स्वायत्त एजेंट्स के लिए Seccomp-bpf

पहली बार हमारे किसी एजेंट ने वह चीज़ डिलीट की जो उसे नहीं करनी चाहिए थी, वह मंगलवार रात 21:47 बजे का समय था। मुझे वक़्त इसलिए याद है क्योंकि डिस्क फुल का अलर्ट ठीक उसी समय आया जब मैं स्क्रीन बंद कर रहा था। एजेंट, एक प्रिविलेज एस्केलेशन CTF पर तर्क की एक श्रृंखला के बीच में, यह तय कर बैठा था कि "अस्थायी आर्टिफैक्ट्स साफ़ करने" का सबसे अच्छा तरीका find / -name "*.tmp" -delete चलाना है। उसने कुछ भी क्रिटिकल नहीं छुआ क्योंकि कंटेनर आइसोलेटेड था, लेकिन उसने उस बेस इमेज को ज़रूर बेकार कर दिया जिसे हम फ़ज़िंग रनर्स के लिए इस्तेमाल करते थे। हमें अगली सुबह उसे फिर से बनाना पड़ा।

उस रात मैंने नोटबुक में एक वाक्य लिखा था जो आज भी मॉनिटर पर चिपका है: "एक LLM यह नहीं समझता कि 'अपरिवर्तनीय' का मतलब क्या है"। वहीं से वह सैंडबॉक्सिंग मॉडल जन्मा जिसे आज Gwaihir CLI—हमारा एक्ज़ीक्यूशन इंजन—इस्तेमाल करता है। और जिस टुकड़े को ट्यून करने में हमें सबसे ज़्यादा मेहनत लगी वह था seccomp-bpf।

seccomp-bpf क्यों और कुछ और क्यों नहीं

उचित सवाल यह है: जब Docker, gVisor, Firecracker और Kata मौजूद हैं, तो हम हाथ से BPF फ़िल्टर लिखने में क्यों उलझें? छोटा जवाब यह है कि इनमें से कोई भी उस एजेंट के वर्कफ़्लो में फिट नहीं बैठता जो ऑफेंसिव टूल्स को झोंक-झोंक कर चलाता है।

runc मोड में Docker होस्ट के साथ कर्नेल साझा करता है, और इसकी डिफ़ॉल्ट seccomp प्रोफ़ाइल 300 से ज़्यादा syscalls को पास होने देती है। यह एक वेब सर्विस के लिए ठीक है, उस चीज़ के लिए नहीं जो रनटाइम में ptrace या kexec_load कॉल करने का फैसला कर सकती है। Firecracker (Agache et al., 2020) हमें प्रति एजेंट एक microVM देता था, पर हर nmap -sS इन्वोकेशन के लिए VM बूट करने की लागत हमारे लेटेंसी बजट को तोड़ देती थी: 125 ms बनाम seccomp लगाए हुए fork+exec के 4 ms।

gVisor मजबूत उम्मीदवार था। इसका Sentry यूज़रस्पेस में syscalls को इंटरसेप्ट करता है और कर्नेल सरफ़ेस को ज़बरदस्त रूप से घटाता है। पर Young et al. की HotCloud 19 स्टडी ने जो मापा था, उसकी पुष्टि बाद में हमारे ख़ुद के बेंचमार्क पर भी हुई: फ़ाइल ओपन 216 गुना धीमे, syscalls 2.2 गुना धीमे। जब कोई एजेंट 200 थ्रेड्स के साथ किसी एंडपॉइंट पर ffuf छोड़ता है, तो वह पेनल्टी साफ़ दिखती है।

दूसरी ओर seccomp-bpf लगभग मुफ़्त है। फ़िल्टर BPF में कंपाइल होता है, प्रोसेस डिस्क्रिप्टर में बैठता है, और प्रति syscall की लागत दर्जनों नैनोसेकंड के क्रम की होती है। और सबसे ज़रूरी: इसे हम कंट्रोल करते हैं, नियम-दर-नियम। जैसा कि कर्नेल डाक्यूमेंटेशन सही ही कहता है, "यह अपने आप में सैंडबॉक्स नहीं है, यह सैंडबॉक्स डेवलपर्स के इस्तेमाल के लिए एक टूल है"[1]। हमें ठीक यही चाहिए था।

प्रति टूल न्यूनतम प्रोफ़ाइलें

मॉडल प्रति टूल है, प्रति एजेंट नहीं। जब Gwaihir nmap चलाने का फैसला करता है, वह sqlmap की प्रोफ़ाइल लोड नहीं करता। एजेंट जिस भी बाइनरी को इन्वोक कर सकता है, उसकी अपनी .scprofile फ़ाइल होती है जो strace -c से ली गई ट्रेस में देखे गए syscalls के न्यूनतम सेट का वर्णन करती है, जिसे असली इस्तेमाल के कई दौरों से और बढ़ाया जाता है।

बनाने की प्रक्रिया उबाऊ है, और इसीलिए यह काम करती है। हम टूल को किसी लैब टार्गेट पर छोड़ते हैं जबकि perf trace चल रहा होता है, syscalls की सूची इकट्ठा करते हैं, उसे डॉक्यूमेंटेशन से क्रॉस-चेक करते हैं, और जो कुछ भी साफ़ तौर पर ख़तरनाक है उसे हटा देते हैं भले ही वह दिखा हो (उदाहरण के लिए, nmap को कभी unshare या mount की ज़रूरत नहीं पड़नी चाहिए)। जो बचता है वही baseline है।

एक सामान्य TCP SYN स्कैन में nmap के लिए यह सूची एक पन्ने में आ जाती है। sqlmap के लिए लंबी है क्योंकि Python इंटरप्रिटर ज़्यादा चीज़ें माँगता है। Go में लिखे गए ffuf के लिए सरप्राइज़ हैं: Go रनटाइम rt_sigaction और mmap को औद्योगिक मात्रा में कॉल करता है, और इनमें से कोई एक भी छूट जाए तो प्रोसेस बिना किसी स्पष्ट डायग्नोसिस के हैंग हो जाती है।

Sentinel के साथ नेगोशिएशन प्रोटोकॉल

एजेंट्स के लिए चीज़ें यहीं से दिलचस्प होती हैं। एक स्थैतिक प्रोफ़ाइल काफ़ी नहीं है। अगर LLM बीच एक्ज़ीक्यूशन में तय करता है कि किसी फाइंडिंग को समृद्ध करने के लिए उसे DNS रिज़ॉल्व करना है, वह socket(AF_NETLINK, ...) कॉल करेगा, जो nmap की प्रोफ़ाइल में नहीं है। बिना और किसी मेकेनिज़्म के, प्रोसेस को SIGSYS मिलेगा और वह मर जाएगी।

हमने जो किया वह यह था कि चाइल्ड प्रोसेस और एक सुपरवाइज़र—जिसे हम Sentinel कहते हैं—के बीच एक seccomp नोटिफ़िकेशन चैनल (SECCOMP_RET_USER_NOTIF, कर्नेल 5.0[2] से उपलब्ध) डाल दिया। जब फ़िल्टर को कोई "negotiable" मार्क की हुई syscall मिलती है, वह प्रोसेस को सस्पेंड करता है और अनुरोध Sentinel को भेजता है। Sentinel एक डिक्लेरेटिव पॉलिसी के विरुद्ध मूल्यांकन करता है, वैकल्पिक रूप से मॉडल से एक छोटा सा प्रॉम्प्ट पूछता है—जैसे "क्या 10.10.0.0/8 के लिए यह connect() मौजूदा टास्क द्वारा जायज़ है?"—और जारी रखें या निरस्त करें लौटाता है।

यह पैटर्न AgentBound (Securing AI Agent Execution, 2025) जैसा है, पर MCP के बजाय कर्नेल स्तर पर। महत्वपूर्ण अंतर यह है कि एजेंट Sentinel को बायपास नहीं कर सकता: BPF फ़िल्टर execve से पहले NO_NEW_PRIVS के साथ लोड किया जाता है, इसलिए कोई दुर्भावनापूर्ण बाइनरी भी इसे उलट नहीं सकती।

एक असली फ़िल्टर

यह उस प्रोफ़ाइल का एक सरलीकृत अंश है जिसका हम nmap के लिए इस्तेमाल करते हैं, जिसे libseccomp के साथ Rust में लिखा गया है (Gwaihir रनटाइम 13.2 MB का है और यह तर्क के लिए Rust तथा ट्रेसिंग के हॉट पाथ्स के लिए Zig के मिश्रण से बना है)।

use libseccomp::*;

fn build_nmap_profile() -> Result {
    // Acción por defecto: SIGSYS y traza al supervisor.
    let mut ctx = ScmpFilterContext::new(ScmpAction::Trap)?;

    // I/O básico
    for sc in &["read", "write", "close", "fstat", "lseek",
                "openat", "pread64", "pwrite64"] {
        ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
    }

    // Memoria
    for sc in &["mmap", "munmap", "mprotect", "brk", "madvise"] {
        ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
    }

    // Red: solo lo que nmap necesita para SYN scan
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("socket")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("setsockopt")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("sendto")?)?;
    ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("recvfrom")?)?;

    // Negociables: el agente puede pedirlas vía Sentinel.
    ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("connect")?)?;
    ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("execve")?)?;

    // Prohibidas explícitamente.
    for sc in &["ptrace", "kexec_load", "init_module",
                "delete_module", "reboot", "mount", "unshare"] {
        ctx.add_rule(ScmpAction::KillProcess, ScmpSyscall::from_name(sc)?)?;
    }

    ctx.set_filter_attr(ScmpFilterAttr::CtlNnp, 1)?;
    Ok(ctx)
}

तीन डिटेल्स जो मायने रखते हैं। पहला, डिफ़ॉल्ट एक्शन SCMP_ACT_TRAP है, SCMP_ACT_KILL नहीं। वजह डायग्नॉस्टिक है: हम चाहते हैं कि SIGSYS हैंडलर प्रोसेस मरने से पहले syscall को पकड़कर लॉग कर ले। दूसरा, negotiable syscalls SCMP_ACT_NOTIFY का इस्तेमाल करती हैं, ALLOW का नहीं; यही चीज़ Sentinel के साथ चैनल खोलती है। तीसरा, निषिद्ध syscalls पर डिफ़ॉल्ट से कवर होने के बावजूद स्पष्ट KILL_PROCESS रखा गया है, क्योंकि हम चाहते हैं कि भविष्य के ऑडिट में वह फ़ैसला साफ़ पढ़ा जा सके।

ठोकर खाकर सीखे गए ट्रेड-ऑफ़

पहला है पोर्टेबिलिटी। Syscall नंबर आर्किटेक्चर के बीच बदलते हैं (x86_64, aarch64, riscv64) और libseccomp इसे काफ़ी अच्छी तरह से एब्स्ट्रैक्ट कर देता है, पर कुछ edge cases हैं। 32-बिट पर मल्टीप्लेक्स्ड socketcall, सिर्फ़ x86 पर मौजूद arch_prctl, हाल के कर्नेल में आया statx। हम प्रति आर्किटेक्चर एक टेस्ट मैट्रिक्स रखते हैं।

दूसरा है डिबगिंग। जब कोई प्रोफ़ाइल किसी ऐसी syscall की वजह से प्रोसेस मार देती है जो सूची में नहीं थी, तो जो संदेश आप देखते हैं वह है Bad system call (core dumped)। और कुछ नहीं। हमें एक रैपर लिखना पड़ा जो SIGSYS के siginfo_t को पार्स करे, syscall नंबर रिपोर्ट करे, उसे टेबल से क्रॉस-चेक करे, और कुछ पढ़ने योग्य दिखाए। पहली बार जब हमने core dump की जगह "statx blocked by profile nmap.scprofile, suggest adding to baseline" देखा, तो हम राहत से लगभग रो पड़े थे।

तीसरा है फ़ॉल्स पॉज़िटिव्स। जब कोई libc अपडेट होकर clone के बजाय clone3 इस्तेमाल करने लगती है, आपकी सारी प्रोफ़ाइलें एक साथ टूट जाती हैं। हमने यह तब सीखा जब glibc 2.34 हमारी एक बेस इमेज में आ गया। अब हमारे पास एक साप्ताहिक जॉब है जो baselines को फिर से जेनरेट करती है और अगर डिफ़ संदेहास्पद रूप से बड़ा हो तो हमें चेताती है।

चौथा, और जिसने हमें सबसे ज़्यादा चौंकाया, वह है बहुत बड़े फ़िल्टरों का ओवरहेड। BPF हर syscall पर चलता है, और सैकड़ों कैस्केडिंग नियमों वाला फ़िल्टर अपना असर दिखाता है। 180-नियमों वाली प्रोफ़ाइल के साथ हमने syscall-इंटेंसिव वर्कलोड पर 3-4% पेनल्टी मापी, जबकि 40-नियमों वाली के साथ सिर्फ़ 0.8%। नियमों को फिर से क्रम में लगाकर—ताकि बार-बार आने वाले syscalls पहले मूल्यांकित हों—हमने उस समय का अधिकांश हिस्सा वापस पा लिया।

काश हम यह पहले जान लेते

तीन बातें। पहली: प्रोफ़ाइल डेवलपमेंट के दौरान TRAP के बजाय SCMP_ACT_LOG से शुरू करो। सब कुछ लॉग करो, टूल को असली वर्कलोड के साथ पूरे एक दिन चलाओ, फिर सूची बनाओ। हमने उल्टे रास्ते से शुरू किया और क्रैश का पीछा करते-करते हफ़्ते गँवा दिए।

दूसरी: यूज़र नोटिफ़िकेशन (USER_NOTIF) ही वह चीज़ है जो seccomp को एजेंट्स के लिए वाकई उपयोगी बनाती है। उसके बिना, एक स्थैतिक प्रोफ़ाइल एक कोर्सेट है। उसके साथ, आपको प्रिविलेज सेपरेशन की कुर्बानी दिए बिना डायनैमिक पॉलिसी मिलती है।

तीसरी: पहले दिन से ही असली कॉस्ट नापो। प्रकाशित बेंचमार्क उपयोगी हैं, पर आपके वर्कलोड अजीब हैं। एक ऐसा एजेंट जो किसी छोटे टूल को प्रति सेकंड 50 बार कॉल करता है, उसका कॉस्ट प्रोफ़ाइल किसी लंबे समय तक चलने वाले सर्वर से बहुत अलग होता है।

फ़िल्टर हर चीज़ से नहीं बचाता। वह मूर्खता से बचाता है। और पता चलता है कि जब आप एक LLM को खुला छोड़ देते हैं, तो जो ग़लत होता है उसका 90% मूर्खता ही होती है।

आज Gwaihir उन एक्सप्लॉइट्स को चलाता है जो मॉडल प्रस्तावित करते हैं, और हम में से कोई भी किसी विपत्ति की प्रतीक्षा में स्क्रीन नहीं देख रहा होता। इसलिए नहीं कि हम मॉडल पर भरोसा करते हैं, बल्कि इसलिए कि हम फ़िल्टर पर भरोसा करते हैं। और यह, उस मंगलवार 21:47 के बाद, ज़िंदगी बदलने वाली बात है।

References

  1. Linux Kernel Documentation (2024). Seccomp BPF (SECure COMPuting with filters).
  2. Drysdale, M. y Corbet, J. (2024). seccomp(2) - Linux manual page, man7.org.
  3. Young, E. et al. (2019). The True Cost of Containing: A gVisor Case Study. USENIX HotCloud 19.
  4. Agache, A. et al. (2020). Firecracker: Lightweight Virtualization for Serverless Applications. USENIX NSDI 20.
  5. seccomp/libseccomp (2024). libseccomp: Enhanced Linux seccomp library.