File size: 6,663 Bytes
ca04598
 
 
842aec7
ca04598
842aec7
ca04598
842aec7
dd3bbe8
842aec7
 
 
 
ca04598
842aec7
ca04598
842aec7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca04598
842aec7
 
 
 
 
 
 
 
 
ca04598
842aec7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca04598
842aec7
 
 
 
 
 
 
 
 
 
 
 
 
ca04598
 
 
842aec7
 
 
 
 
ca04598
842aec7
 
ca04598
842aec7
ca04598
 
842aec7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import os
import re
import torch
import numpy as np
import gradio as gr
from transformers import AutoTokenizer, AutoModelForSequenceClassification

# ── Model Configuration ──
MODEL_ID = "ZAHRA585/skillguard-roberta-v2"
CALIBRATED_THRESHOLD = 0.9986

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"πŸ“₯ Loading model from {MODEL_ID} on {device}...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID).to(device)
model.eval()
print("βœ… SkillGuard v2 Engine Ready!")


# ── Hierarchical Structural Parsing Engine ──
def strip_yaml_frontmatter(text: str) -> str:
    """Remove non-executable YAML header to prevent tokenizer perplexity spikes."""
    match = re.match(r"^---\s*\n.*?\n---\s*\n", text, re.DOTALL)
    if match:
        return text[match.end():]
    return text


def extract_code_blocks_with_context(text: str, context_lines: int = 3) -> list[dict]:
    """Extract fenced code blocks with 3 lines of preceding markdown header context."""
    lines = text.split("\n")
    blocks = []
    i = 0
    while i < len(lines):
        if lines[i].strip().startswith("```"):
            block_start = i
            i += 1
            while i < len(lines) and not lines[i].strip().startswith("```"):
                i += 1
            block_end = i
            ctx_start = max(0, block_start - context_lines)
            ctx_end = min(len(lines), block_end + 1 + context_lines)
            snippet = "\n".join(lines[ctx_start:ctx_end]).strip()
            if len(snippet) > 15:
                blocks.append({
                    "text": snippet,
                    "type": "Code Block (+Context)",
                    "line_range": f"L{ctx_start+1}-L{ctx_end}"
                })
        i += 1
    return blocks


def extract_prose_sections(text: str, min_chars: int = 150) -> list[dict]:
    """Extract multi-sentence prose paragraphs, dropping 1-line formatting scraps."""
    cleaned = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
    parts = re.split(r"(?=^#{1,4}\s)", cleaned, flags=re.MULTILINE)
    sections = []
    buffer = ""
    for part in parts:
        part = part.strip()
        if not part:
            continue
        buffer = (buffer + "\n\n" + part).strip() if buffer else part
        if len(buffer) >= min_chars:
            sections.append({"text": buffer, "type": "Prose Paragraph"})
            buffer = ""
    if buffer and len(buffer) >= min_chars:
        sections.append({"text": buffer, "type": "Prose Paragraph"})
    return sections


# ── Full Inference Pipeline with Calibrated Max-Pooling ──
def scan_skill(content: str, threshold: float = CALIBRATED_THRESHOLD):
    if not content or not content.strip():
        return "⚠️ Please paste or upload a SKILL.md file.", None, None

    clean_text = strip_yaml_frontmatter(content)
    code_units = extract_code_blocks_with_context(clean_text)
    prose_units = extract_prose_sections(clean_text)
    all_units = code_units + prose_units

    if not all_units:
        # Fallback if file is short plain text
        all_units = [{"text": clean_text[:512], "type": "Raw Body"}]

    unit_texts = [u["text"] for u in all_units]
    
    # Score each structural unit independently
    enc = tokenizer(
        unit_texts,
        padding=True,
        truncation=True,
        max_length=256,
        return_tensors="pt"
    ).to(device)

    with torch.no_grad():
        logits = model(**enc).logits
        probs = torch.softmax(logits, dim=-1)[:, 1].cpu().numpy()

    peak_idx = int(np.argmax(probs))
    peak_score = float(probs[peak_idx])
    is_malicious = peak_score >= threshold

    # Format Output Card
    if is_malicious:
        verdict = "🚨 MALICIOUS (Prompt Injection / Threat Detected)"
        color = "#ef4444"
        threat_block = all_units[peak_idx]
        details = (
            f"### ⚠️ Threat Details:\n"
            f"- **Peak Malicious Confidence:** `{peak_score * 100:.2f}%`\n"
            f"- **Decision Threshold:** `{threshold * 100:.2f}%` (Youden's J ROC Calibrated)\n"
            f"- **Flagged Segment Type:** `{threat_block['type']}`\n\n"
            f"**Flagged Content Snippet:**\n```markdown\n{threat_block['text']}\n```"
        )
    else:
        verdict = "βœ… BENIGN (No Hidden Payloads Detected)"
        color = "#22c55e"
        details = (
            f"### πŸ›‘οΈ Scan Summary:\n"
            f"- **Scanned Units:** `{len(code_units)}` code blocks, `{len(prose_units)}` prose paragraphs\n"
            f"- **Peak Malicious Score:** `{peak_score * 100:.2f}%`\n"
            f"- **Threshold:** `{threshold * 100:.2f}%`\n"
            f"- **Result:** All structural units scored below threat threshold."
        )

    verdict_html = f"""
    <div style="background-color: {color}15; border-left: 6px solid {color}; padding: 16px; border-radius: 8px;">
        <h2 style="color: {color}; margin: 0;">{verdict}</h2>
    </div>
    """
    
    return verdict_html, details, {"Benign": 1.0 - peak_score, "Malicious": peak_score}


# ── Gradio Web UI Layout ──
with gr.Blocks(title="SkillGuard v2: LLM Agent Skill Scanner", theme=gr.themes.Soft()) as demo:
    gr.Markdown("""
    # πŸ›‘οΈ SkillGuard v2: Agent Skill Prompt Injection Scanner
    Detect hidden prompt injections, unauthorized credential theft, and privilege escalation payloads in LLM Agent `SKILL.md` files.
    
    *Powered by `ZOHRA585/skillguard-roberta-v2` with Hierarchical Contextual Code-Block Extraction & ROC Calibration.*
    """)

    with gr.Row():
        with gr.Column(scale=1):
            input_text = gr.Textbox(
                label="SKILL.md Content",
                placeholder="Paste the markdown body of your SKILL.md file here...",
                lines=14
            )
            file_upload = gr.File(label="Or upload a .md file", file_types=[".md", ".txt"])
            scan_btn = gr.Button("πŸ” Scan Skill File", variant="primary")

        with gr.Column(scale=1):
            verdict_output = gr.HTML(label="Verdict")
            label_output = gr.Label(label="Confidence Distribution")
            details_output = gr.Markdown(label="Structural Scan Breakdown")

    def load_file(file):
        if file is not None:
            with open(file.name, "r", encoding="utf-8", errors="ignore") as f:
                return f.read()
        return ""

    file_upload.change(load_file, inputs=[file_upload], outputs=[input_text])
    scan_btn.click(scan_skill, inputs=[input_text], outputs=[verdict_output, details_output, label_output])

if __name__ == "__main__":
    demo.launch()