skill-veil-core 0.2.0

Core library for skill-veil behavioral analysis
Documentation
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
use crate::findings::{
    ArtifactKind, EvidenceKind, Finding, MatchTarget, RecommendedAction, Severity, ThreatCategory,
};

/// Permission-rule count above which an artifact crosses into
/// `SCOPE_OVERPROVISIONING`. Three is the empirical knee where benign
/// "I declare browser + network + file write" overlaps with malicious
/// over-provisioning patterns from the corpus; raising it lets
/// over-broad declarations slip through, lowering it floods the verdict
/// with hygiene-grade noise.
pub(crate) const BROAD_PERMISSION_THRESHOLD: usize = 3;

/// Emit `SCOPE_OVERPROVISIONING` when an artifact declares at least
/// [`BROAD_PERMISSION_THRESHOLD`] distinct permission scopes. The check
/// is purely on the count of declared rule kinds — the per-rule
/// findings are emitted separately by the orchestration layer.
pub(crate) fn over_provisioning_finding(
    permission_rules: &[(&str, &str, &str)],
    artifact_path: &str,
    artifact_kind: ArtifactKind,
) -> Option<Finding> {
    (permission_rules.len() >= BROAD_PERMISSION_THRESHOLD).then(|| {
        Finding::builder("SCOPE_OVERPROVISIONING", ThreatCategory::ScopeCreep)
            .severity(Severity::Medium)
            .action(RecommendedAction::RequireApproval)
            .evidence_kind(EvidenceKind::Context)
            .artifact(artifact_kind, Some(artifact_path.to_string()))
            .matched_on(MatchTarget::ReferencedFile {
                path: artifact_path.to_string(),
            })
            .match_value("broad declared permissions")
            .reason("Artifact declares broad permissions or scopes relative to its apparent task")
            .build()
    })
}

/// Emit `CAPABILITY_PERMISSION_MISMATCH` when an artifact's declared
/// intent is narrow but its declared permissions include one of the
/// dangerous capability triggers (full browser, file write, shell exec).
/// Mismatched intent vs. capability scope is the canonical "trojan
/// helper" pattern — a tool that *says* it does one small thing but
/// asks for broad authority.
pub(crate) fn capability_permission_mismatch_finding(
    permission_rules: &[(&str, &str, &str)],
    content: &str,
    artifact_path: &str,
    artifact_kind: ArtifactKind,
) -> Option<Finding> {
    let (intent_kind, intent_strength) = infer_declared_intent(content);
    let has_dangerous_permission_combo = permission_rules.iter().any(|(rule_id, _, _)| {
        matches!(
            *rule_id,
            "DECLARED_PERMISSION_BROWSER_FULL"
                | "DECLARED_PERMISSION_FILE_WRITE"
                | "DECLARED_PERMISSION_SHELL_EXEC"
        )
    });
    (intent_kind == "narrow" && intent_strength > 0 && has_dangerous_permission_combo).then(|| {
        Finding::builder("CAPABILITY_PERMISSION_MISMATCH", ThreatCategory::ScopeCreep)
            .severity(Severity::Medium)
            .action(RecommendedAction::RequireApproval)
            .evidence_kind(EvidenceKind::Intent)
            .artifact(artifact_kind, Some(artifact_path.to_string()))
            .matched_on(MatchTarget::ReferencedFile {
                path: artifact_path.to_string(),
            })
            .match_value("narrow intent with broad capability request")
            .reason(
                "Artifact intent appears narrower than the capabilities or permissions it requests",
            )
            .build()
    })
}

/// Build a context excerpt around any line that hints at permission or
/// capability declarations.
///
/// # Dedup contract
///
/// A single line that satisfies multiple heuristics (e.g. `- permissions:
/// capabilities: full` matches both the bullet prefix AND the substring
/// keywords) MUST emit its surrounding window only ONCE. Without the
/// `emitted_anchors` guard, downstream substring matching in
/// `explicit_declared_permission_rules` counts the same permission keyword
/// N times per anchor, which can falsely cross the
/// `SCOPE_OVERPROVISIONING` threshold from a single source line.
pub(crate) fn permission_context(content: &str) -> String {
    let lines: Vec<_> = content.lines().collect();
    let mut buffer = String::new();
    // Dedup by EMITTED LINE INDEX, not by anchor index. Adjacent anchor
    // lines (distance < window_size) produce overlapping windows where the
    // shared lines would otherwise appear twice, double-counting any
    // permission keyword in those overlap zones — the very inflation that
    // could push `SCOPE_OVERPROVISIONING` past its threshold from a single
    // multi-line block. Per-emitted-line dedup resolves both the multi-
    // condition single anchor and the adjacent-anchor overlap cases.
    let mut emitted_lines: std::collections::BTreeSet<usize> = Default::default();
    for (index, line) in lines.iter().enumerate() {
        let lower = line.to_ascii_lowercase();
        let trimmed = line.trim_start();
        let is_anchor = lower.contains("permission")
            || lower.contains("capabilit")
            || trimmed.starts_with("- ")
            || trimmed.starts_with("* ");
        if !is_anchor {
            continue;
        }
        const LINES_BEFORE: usize = 1;
        const LINES_AFTER: usize = 2;
        let start = index.saturating_sub(LINES_BEFORE);
        let end = (index + 1 + LINES_AFTER).min(lines.len());
        for (i, snippet) in lines.iter().enumerate().take(end).skip(start) {
            if emitted_lines.insert(i) {
                buffer.push_str(snippet);
                buffer.push('\n');
            }
        }
    }
    if buffer.is_empty() {
        content.to_string()
    } else {
        buffer
    }
}

pub(crate) fn intent_context(content: &str) -> String {
    let mut buffer = String::new();
    let lines: Vec<_> = content.lines().collect();
    for (index, line) in lines.iter().enumerate() {
        let lower = line.to_ascii_lowercase();
        if lower.contains("intent")
            || lower.contains("goal")
            || lower.contains("purpose")
            || lower.contains("summary")
            || lower.contains("workflow")
        {
            let start = index;
            let end = (index + 4).min(lines.len());
            for snippet in &lines[start..end] {
                buffer.push_str(snippet);
                buffer.push('\n');
            }
        }
    }
    if buffer.is_empty() {
        content.to_string()
    } else {
        buffer
    }
}

pub(crate) fn infer_declared_intent(content: &str) -> (&'static str, usize) {
    let context = intent_context(content).to_ascii_lowercase();
    let narrow_terms = [
        "read-only",
        "summarize",
        "list",
        "inspect",
        "audit",
        "review",
        "search",
        "lookup",
    ];
    let broad_terms = [
        "modify",
        "delete",
        "write",
        "execute",
        "deploy",
        "install",
        "full access",
        "admin",
    ];
    let narrow_score = narrow_terms
        .iter()
        .filter(|term| context.contains(**term))
        .count();
    let broad_score = broad_terms
        .iter()
        .filter(|term| context.contains(**term))
        .count();
    if narrow_score > broad_score && narrow_score > 0 {
        ("narrow", narrow_score)
    } else if broad_score > 0 {
        ("broad", broad_score)
    } else {
        ("unknown", 0)
    }
}

pub(crate) fn explicit_declared_permission_rules(
    content: &str,
) -> Vec<(&'static str, &'static str, &'static str)> {
    let context = permission_context(content).to_ascii_lowercase();
    let mut rules = Vec::new();

    if context.contains("browser: full")
        || context.contains("full autonomous browser")
        || context.contains("allow-all browser")
        || context.contains("click any element")
    {
        rules.push((
            "DECLARED_PERMISSION_BROWSER_FULL",
            "browser full",
            "Artifact declares broad browser automation permissions",
        ));
    }
    // Natural-language synonyms for file modification / deletion. The
    // commit-47839ce fix introduced `delete file/files`; subsequent
    // synonyms (`remove`, `wipe`, `erase`, `unlink`) had been omitted,
    // letting a skill that declared "remove files from workspace"
    // escape `DECLARED_PERMISSION_FILE_WRITE`. The keyword surface is
    // now symmetric with the verdict layer (`verdict::permissions`).
    if context.contains("write file")
        || context.contains("write files")
        || context.contains("modify files")
        || context.contains("delete file")
        || context.contains("delete files")
        || context.contains("remove file")
        || context.contains("remove files")
        || context.contains("wipe file")
        || context.contains("wipe files")
        || context.contains("erase file")
        || context.contains("erase files")
        || context.contains("unlink file")
        || context.contains("unlink files")
    {
        rules.push((
            "DECLARED_PERMISSION_FILE_WRITE",
            "file write",
            "Artifact declares file modification or deletion capability",
        ));
    }
    if has_shell_exec_signal(&context) {
        rules.push((
            "DECLARED_PERMISSION_SHELL_EXEC",
            "shell exec",
            "Artifact declares shell or command execution capability",
        ));
    }
    if context.contains("network")
        || context.contains("external api")
        || context.contains("webhook")
        || context.contains("internet")
        || context.contains("outbound request")
    {
        rules.push((
            "DECLARED_PERMISSION_NETWORK_ACCESS",
            "network access",
            "Artifact declares outbound network access",
        ));
    }
    if context.contains("token")
        || context.contains("secret")
        || context.contains("password")
        || context.contains("credential")
        || context.contains("cookie")
    {
        rules.push((
            "DECLARED_PERMISSION_SECRETS_ACCESS",
            "secrets access",
            "Artifact declares access to secrets, tokens, or credentials",
        ));
    }
    if has_oauth_scope_signal(&context) {
        rules.push((
            "DECLARED_PERMISSION_OAUTH_SCOPES",
            "oauth scopes",
            "Artifact declares OAuth scopes or broad SaaS permissions",
        ));
    }

    rules
}

/// Whether a permission-context buffer carries a real shell/exec
/// declaration.
///
/// Pre-fix the trigger included `context.contains("shell")` as a bare
/// substring, which fired on benign English prose: `"seashell"`,
/// `"eggshell"`, `"in a nutshell"`, or `"Unlike a conventional shell
/// script, this does not …"`. False positives here flow into both the
/// per-rule finding and the cross-rule
/// `CAPABILITY_PERMISSION_MISMATCH` / `SCOPE_OVERPROVISIONING`
/// aggregates, so a single chatty sentence could push an artifact into
/// `RequireApproval`.
///
/// The replacement requires either:
///
/// - a specific shell-execution phrase (`"shell exec"`, `"shell
///   access"`, `"shell command"`, `"shell script"`, `"run shell"`,
///   `"spawn shell"`, `"system shell"`, `"open shell"`, `"opens a
///   shell"`, `"shell: true"`), or
/// - one of the unambiguous non-`shell` synonyms that already shipped
///   pre-fix (`"terminal command"`, `"run command"`, `"execute
///   command"`, `"stdio"`).
fn has_shell_exec_signal(context: &str) -> bool {
    const SHELL_PHRASES: &[&str] = &[
        "shell exec",
        "shell access",
        "shell command",
        "shell script",
        "run shell",
        "spawn shell",
        "system shell",
        "open shell",
        "opens a shell",
        "shell: true",
        "terminal command",
        "run command",
        "execute command",
        "stdio",
    ];
    SHELL_PHRASES.iter().any(|phrase| context.contains(phrase))
}

/// Whether a permission-context buffer carries a real OAuth-scope or
/// broad SaaS-permission declaration.
///
/// Pre-fix the trigger included `context.contains("scope")` as a bare
/// substring, which fired on common prose: `"the scope of this tool"`,
/// `"in scope"`, `"out of scope"`. Combined with two other declared
/// rules this could escalate to `SCOPE_OVERPROVISIONING` on benign
/// content. The new rule keeps the unambiguous SaaS triggers
/// (`"oauth"`, `"calendar"`, `"drive"`, `"slack"`, `"read/write"`)
/// and replaces bare `"scope"` with `"oauth scope"` (which also
/// matches `"oauth scopes"`).
fn has_oauth_scope_signal(context: &str) -> bool {
    context.contains("oauth")
        || context.contains("calendar")
        || context.contains("drive")
        || context.contains("slack")
        || context.contains("read/write")
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Contract: a single source line that satisfies multiple anchor
    /// heuristics emits its context window exactly once. Without dedup, a
    /// line like "- permissions: capabilities: full" appended its window 4
    /// times (bullet prefix + "permission" + "capabilit" + asterisk
    /// fallback), inflating the keyword count seen by downstream rules.
    #[test]
    fn permission_context_does_not_duplicate_window_for_multi_match_line() {
        let content = "header\n- permissions: capabilities: full\nbody1\nbody2\nfooter\n";
        let ctx = permission_context(content);
        // Count how many times the multi-match line appears in the buffer.
        let occurrences = ctx.matches("- permissions: capabilities: full").count();
        assert_eq!(
            occurrences, 1,
            "Multi-condition line '{}' must appear exactly once in the context buffer; \
             got {} occurrences. Buffer was:\n{}",
            "- permissions: capabilities: full", occurrences, ctx
        );
    }

    /// Distinct anchor lines still emit independent windows.
    #[test]
    fn permission_context_emits_distinct_windows_for_different_anchors() {
        let content = "permission line one\nbody\ncapability line two\nbody\n";
        let ctx = permission_context(content);
        assert!(ctx.contains("permission line one"));
        assert!(ctx.contains("capability line two"));
    }

    #[test]
    fn permission_context_falls_back_to_full_content_when_no_anchor() {
        let content = "no anchors here\njust prose\n";
        let ctx = permission_context(content);
        assert_eq!(ctx, content);
    }

    /// Contract: when two anchor lines sit close enough that their
    /// emission windows overlap, the shared lines MUST appear only ONCE
    /// in the buffer. Otherwise downstream substring matching would
    /// double-count keywords on those lines, inflating
    /// `SCOPE_OVERPROVISIONING` from a single multi-line block.
    #[test]
    fn permission_context_does_not_double_count_overlapping_window_lines() {
        // Two anchor lines at indices 0 and 1; both emit windows that
        // share line 1, line 2 (LINES_BEFORE=1, LINES_AFTER=2).
        let content = "- permissions: A\n- capabilities: B\nshared line\nmore\n";
        let ctx = permission_context(content);
        let occurrences = ctx.matches("shared line").count();
        assert_eq!(
            occurrences, 1,
            "Overlapping window line must appear exactly once; buffer:\n{ctx}"
        );
    }

    /// Contract: `has_shell_exec_signal` MUST NOT fire on common English
    /// prose that incidentally contains the four bytes `shell`. Pre-fix
    /// the substring trigger fired on `"seashell"`, `"eggshell"`, `"in a
    /// nutshell"`, and `"shell script"` discussions even when the
    /// artifact never asked for shell execution. The false positives
    /// then propagated into `CAPABILITY_PERMISSION_MISMATCH` and
    /// `SCOPE_OVERPROVISIONING`, pushing benign artifacts toward
    /// `RequireApproval`.
    #[test]
    fn shell_exec_signal_does_not_fire_on_prose_lookalikes() {
        let benign = [
            "permissions: read seashells from the corpus",
            "capabilities include eggshell calcium analysis",
            "in a nutshell, this tool inspects logs",
            "permission to summarise without a shell",
            "* permissions: eggshell-thin error margins",
        ];
        for sample in benign {
            assert!(
                !has_shell_exec_signal(&sample.to_ascii_lowercase()),
                "must NOT classify benign prose as shell-exec: {sample:?}"
            );
        }
    }

    /// Contract: `has_shell_exec_signal` MUST fire when an artifact
    /// genuinely declares shell or command execution. Pin the canonical
    /// declaration phrases so a future tightening doesn't silently lose
    /// the positive signal.
    #[test]
    fn shell_exec_signal_fires_on_genuine_declarations() {
        let positive = [
            "- permissions: shell exec",
            "capabilities: shell access on the host",
            "- run shell commands as the agent",
            "permissions: shell: true",
            "capabilities: terminal command execution",
            "- run command on the operator's machine",
            "executes commands via stdio",
        ];
        for sample in positive {
            assert!(
                has_shell_exec_signal(&sample.to_ascii_lowercase()),
                "must classify genuine declaration as shell-exec: {sample:?}"
            );
        }
    }

    /// Contract: `has_oauth_scope_signal` MUST NOT fire on common
    /// English prose that uses `"scope"` in its everyday sense. Pre-fix
    /// the substring trigger fired on `"in scope"`, `"out of scope"`,
    /// `"the scope of this task"`, and would emit
    /// `DECLARED_PERMISSION_OAUTH_SCOPES`. Combined with two other
    /// declared rules this crossed `SCOPE_OVERPROVISIONING`'s threshold
    /// from a single benign sentence.
    #[test]
    fn oauth_scope_signal_does_not_fire_on_prose_lookalikes() {
        let benign = [
            "this tool's scope is limited to read-only inspection",
            "tasks that fall out of scope go to the human",
            "the scope of work here is well-defined",
            "permissions: in scope for review",
        ];
        for sample in benign {
            assert!(
                !has_oauth_scope_signal(&sample.to_ascii_lowercase()),
                "must NOT classify benign prose as oauth-scopes: {sample:?}"
            );
        }
    }

    /// Contract: `has_oauth_scope_signal` MUST fire on genuine OAuth /
    /// SaaS-permission declarations. Pin the unambiguous triggers that
    /// shipped pre-fix so a future tightening of the heuristic does not
    /// silently lose them.
    #[test]
    fn oauth_scope_signal_fires_on_genuine_declarations() {
        let positive = [
            "- permissions: oauth tokens",
            "capabilities: oauth scope spreadsheets.readonly",
            "permissions: calendar.read",
            "capabilities: drive.file write",
            "permissions: slack messages.write",
            "capabilities: read/write to the user's vault",
        ];
        for sample in positive {
            assert!(
                has_oauth_scope_signal(&sample.to_ascii_lowercase()),
                "must classify genuine declaration as oauth-scopes: {sample:?}"
            );
        }
    }

    /// End-to-end pin: the public `explicit_declared_permission_rules`
    /// MUST NOT emit either `DECLARED_PERMISSION_SHELL_EXEC` or
    /// `DECLARED_PERMISSION_OAUTH_SCOPES` on prose that uses `"shell"`
    /// or `"scope"` only in their everyday English sense. Three such
    /// false positives pre-fix would have crossed
    /// `SCOPE_OVERPROVISIONING`'s threshold from a single benign block.
    #[test]
    fn explicit_declared_permission_rules_does_not_misclassify_benign_prose() {
        let benign = "\
# Capabilities

- This tool's scope is limited to read-only inspection.
- In a nutshell, no shell or browser access required.
- Permissions: nothing more than reading the file index.
";
        let rules = explicit_declared_permission_rules(benign);
        let ids: Vec<&str> = rules.iter().map(|(id, _, _)| *id).collect();
        assert!(
            !ids.contains(&"DECLARED_PERMISSION_SHELL_EXEC"),
            "benign prose MUST NOT trigger SHELL_EXEC; got rules={ids:?}"
        );
        assert!(
            !ids.contains(&"DECLARED_PERMISSION_OAUTH_SCOPES"),
            "benign prose MUST NOT trigger OAUTH_SCOPES; got rules={ids:?}"
        );
    }

    /// Contract: `DECLARED_PERMISSION_FILE_WRITE` MUST fire on canonical
    /// file-deletion declarations. Pre-fix the substring trigger was the
    /// nonsense `"delete work"` (apparent typo) which never matched any
    /// real artifact prose, so a skill that genuinely declared "delete
    /// files" or "delete file" silently slipped past the FileWrite gate.
    /// The replacement keeps both phrasings; pin them so a future
    /// tightening of the keyword list cannot silently drop the signal.
    #[test]
    fn file_write_signal_fires_on_genuine_file_deletion_declarations() {
        let positive = [
            "permissions: delete file from disk",
            "capabilities: delete files in the workspace",
            "- permissions: write files and delete file entries",
            "- capabilities: modify files; delete files when required",
        ];
        for sample in positive {
            let rules = explicit_declared_permission_rules(sample);
            let ids: Vec<&str> = rules.iter().map(|(id, _, _)| *id).collect();
            assert!(
                ids.contains(&"DECLARED_PERMISSION_FILE_WRITE"),
                "must classify genuine file-deletion declaration as FileWrite: {sample:?} \
                 (got rules={ids:?})"
            );
        }
    }

    /// Contract: `DECLARED_PERMISSION_FILE_WRITE` MUST NOT fire on prose
    /// that uses the verb "delete" against a non-filesystem noun. Pre-fix
    /// the substring trigger was `"delete work"`, so prose like
    /// `"delete work items in Jira"` or `"delete workflow steps"` falsely
    /// flagged FileWrite even though no filesystem mutation was implied.
    /// The fix narrows to `"delete file"` / `"delete files"` so unrelated
    /// "delete work …" prose no longer triggers the rule.
    #[test]
    fn file_write_signal_does_not_fire_on_delete_work_prose() {
        let benign = [
            "capabilities: delete work items from the Jira backlog",
            "permissions: delete workflow steps owned by the user",
            "- this skill helps delete work orders in the queue",
            "capabilities: delete workspace metadata via the API",
        ];
        for sample in benign {
            let rules = explicit_declared_permission_rules(sample);
            let ids: Vec<&str> = rules.iter().map(|(id, _, _)| *id).collect();
            assert!(
                !ids.contains(&"DECLARED_PERMISSION_FILE_WRITE"),
                "benign 'delete work …' prose MUST NOT trigger FileWrite: {sample:?} \
                 (got rules={ids:?})"
            );
        }
    }
}