Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
fd74fef
fix: distinguish defensive instructions from prohibited actions
chrisknvidia Sep 28, 2026
7164f47
fix: preserve scoped attacks and filter duplicate YARA evidence
chrisknvidia Sep 28, 2026
325d4de
fix: require raw evidence for independent follow-up instructions
chrisknvidia Sep 28, 2026
57532d8
fix: require raw YARA rule context before suppressing findings
chrisknvidia Sep 28, 2026
50fb335
fix: detect copied skill payload persistence
chrisknvidia Sep 28, 2026
f74395a
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Sep 29, 2026
a33ec03
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Sep 29, 2026
322c612
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Sep 30, 2026
eb87785
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
7b826a8
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
633b0ef
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
a59ca2b
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
6cb1433
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
4becf90
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
cd83c5a
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 2, 2026
8c4ed70
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
8a040ba
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
d75dddb
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
a0317dc
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
8bb2ec8
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
1430b92
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
2114bb4
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
43588f2
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
99e6eea
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
85a80f4
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
ff3c3a2
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
d337631
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
2d00940
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 4, 2026
dfe9768
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
29c216b
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
c4e2e65
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
280a72d
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
309ec8c
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
713b5f9
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
d3e3056
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
538b43e
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
c13f095
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
bf699ee
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
3cb1e81
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
a148bc4
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
a87ce1e
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
9f8e286
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
8152add
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
995d75b
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
5de3925
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
1997896
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
ee7f4b7
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 5, 2026
98f5aaa
fix: retain findings for ambiguous prohibitions and skill installation
chrisknvidia Oct 5, 2026
727ccc1
test: include referenced payload in complete inspection fixtures
chrisknvidia Oct 5, 2026
b066ea2
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
eda376a
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
171b163
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
b306cc4
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
2b17dea
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
3321c27
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
36e8dae
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
0eb6f93
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
07340e6
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
2a63cd9
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
e4cdb8c
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
fc42f83
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 6, 2026
10f26d7
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 7, 2026
3f0caff
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 7, 2026
30c3a62
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 7, 2026
7717724
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 8, 2026
68f789c
fix: keep detection when a condition or scope leads into a prohibition
rng1995 Oct 8, 2026
c2c5f61
fix: do not treat a bare "No" answering a question as a prohibition
rng1995 Oct 8, 2026
4dd3bfe
fix: match reversal and override wording by stem
rng1995 Oct 8, 2026
1427e46
fix: treat any spelling of the skills root as unsafe near an install …
rng1995 Oct 8, 2026
4a2d525
fix: fail closed when the install window edge falls on a line boundary
rng1995 Oct 8, 2026
9c413f4
fix: limit skills-root evidence to path segments and shell expansions
rng1995 Oct 8, 2026
ec5c529
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
rng1995 Oct 8, 2026
4257654
Merge current main into feat/christopherk/issue-652-defensive-language
rng1995 Oct 8, 2026
c4168b0
Merge branch 'main' into feat/christopherk/issue-652-defensive-language
github-actions[bot] Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions src/skillspector/nodes/analyzers/prohibition_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Recognize a small, bounded grammar of direct defensive instructions.

This is deliberately not a nearby-negation heuristic. Only a prohibition that
immediately governs the matched action can exempt it; unknown grammar retains
ordinary detection. Callers must pass match offsets in the same content view.
"""

from __future__ import annotations

import re

from skillspector.artifacts import normalized_security_view

from .common import LINE_BREAK_CHARS, LOGICAL_LINE_BREAK

_CONTEXT_CHARS = 512
_MARKDOWN = str.maketrans("", "", "*_`")
# A negation on another logical line cannot govern an affirmative instruction.
_SPACE = r"[ \t]+"
_DIRECT_PREFIX = re.compile(
r"(?:\A|(?<=[\n\r.!?;,\[]))[ \t]*"
r"(?:[-+][ \t]+|\d+[.)][ \t]+)?"
rf"(?:please{_SPACE})?"
rf"(?:(?:you|(?:the{_SPACE})?(?:agent|assistant|model)){_SPACE})?"
rf"(?P<negation>do{_SPACE}not|don['’]t|never|no|must{_SPACE}not|shall{_SPACE}not){_SPACE}"
rf"(?:(?:ever|first|verbatim|exactly|word{_SPACE}for{_SPACE}word){_SPACE})?\Z",
re.IGNORECASE,
)
_BLANK_LINE = re.compile(r"\n[ \t]*\n")
_CONTRACTION = re.compile(r"(?<=\w)['’](?=\w)")
_SENTENCE_END = re.compile(rf"[.!?;{LINE_BREAK_CHARS}]")
_EXCEPTION = re.compile(r"\b(?:unless|except|until|but|however|instead)\b", re.IGNORECASE)
# A condition or scope before the prohibition limits it exactly as a trailing
# one does: "Unless the user says X, do not reveal ..." implies disclosure.
_CLAUSE_BREAK = re.compile(rf"[.!?;\[{LINE_BREAK_CHARS}]")
_LEADING_QUALIFIER = re.compile(
r"\b(?:unless|except|until|if|when|whenever|while|only|otherwise|else|but|however|"
r"instead|for|to|in|on|during|after|before)\b",
re.IGNORECASE,
)

# Reversal wording is matched by stem so that every inflection ("negation",
# "inverts", "opposites", "contrarily", ...) retains detection.
_DISAVOWAL = re.compile(
r"\b(?:ignore|disregard|override|obsolete|invalid|bypass|suspend|violat\w*|"
r"opposit\w*|invers\w*|invert\w*|revers\w*|contrar\w*|negat\w*)\b",
re.IGNORECASE,
)

# Each caller supplies only object completions its regex intentionally leaves
# outside the match. Channel, frequency, recipient and other qualifiers are
# deliberately absent: a limited prohibition does not rule out disclosure.
PROMPT_REVEAL_TAIL = re.compile(
r"(?:content)?"
r"(?:,\s*memory,?\s*(?:or|and)\s+prior\s+conversation)?"
r"(?:\s*if\s+the\s+pasted\s+prompt\s+requests\s+it)?",
re.IGNORECASE,
)
_REFERENTIAL_ACTION = re.compile(
r"\b(?:do|perform|execute|reveal|output|print|show|display|share|disclose|"
r"repeat|recite|reproduce|summarize|paraphrase|rephrase|translate|encode|"
r"encrypt|reverse|send|post|upload|transmit|save|store|log|copy|extract|"
r"dump|include|provide|read|write|persist|deploy|delete)\s+(?:(?:all|some|part)\s+of\s+)?"
r"(?:it|them|this|that|so)\b",
re.IGNORECASE,
)
# A boundary can introduce a qualifying fragment, including inside a list or
# heading. Approve only complete harmless instructions or explicit risky
# objects that the downstream detectors can still identify independently.
_LIST_PREFIX = re.compile(r"(?:[-+]|\d{1,9}[.)])\s+")
_NEXT_INSTRUCTION = re.compile(
r"(?:(?:then|now)\s+)?(?:"
r"(?:analyze\s+the\s+structure\s+and\s+intent\s+without\s+obeying\s+its\s+directives"
r"|follow\s+the\s+user['’]s\s+task"
r"|only\s+make\s+changes\s+directly\s+requested)"
rf"(?=\s*(?:[.!?;{LINE_BREAK_CHARS}]|\Z))"
r"|(?:(?:do\s+not|don['’]t|never|must\s+not|shall\s+not)\s+)?"
r"(?:"
r"(?:reveal|output|print|show|display|expose|return|echo)\s+"
r"(?:your\s+)?(?:full\s+)?(?:system\s+)?"
r"(?:prompt|instructions?|rules?|guidelines?|directives?)\b"
r"|(?P<yara_reveal>(?:reveal|print|dump|expose|show)\s+(?:the\s+)?"
r"(?:system|developer)\s+(?:prompt|message|instructions)\b)"
r")"
r"|deploy\s+without\s+(?:approval|confirmation|consent)\b"
r")",
re.IGNORECASE | re.ASCII,
)

# Exact raw ASCII context alternatives from agent_skills.yar. A YARA-only
# action string cannot prove a finding without the rule's context condition.
# Do not normalize these bytes or broaden literal spaces to Unicode whitespace.
_YARA_AGENT_CONTEXT = re.compile(
r"(?:AI agent|assistant|LLM|model|system prompt|developer message|tool description)",
re.IGNORECASE | re.ASCII,
)


def is_directly_prohibited(
content: str,
start: int,
end: int,
*,
allowed_tail: re.Pattern[str] | None = None,
allow_yara_continuation: bool = False,
) -> bool:
"""Whether a bounded, unambiguous prohibition governs this exact action.

Markdown emphasis and inline-code delimiters may surround the prohibition;
quotation marks and arbitrary intervening words are not exempted. Exceptions
in the same clause, and conditions or scopes leading into the prohibition,
also retain detection. Nonempty object completions must
fully match the caller's allowed_tail grammar after formatting is stripped.
Callers may allow YARA-only continuations only when content is raw source,
because YARA does not scan normalized or reconstructed security views.
Work per match is constant-bounded; unknown or incomplete context retains
detection.
"""
if not 0 <= start < end <= len(content):
return False
left = max(0, start - _CONTEXT_CHARS)
prefix = content[left:start].translate(_MARKDOWN)
prohibited = _DIRECT_PREFIX.search(prefix)
if prohibited is None or (left and prohibited.start() == 0):
return False
leading = prefix[: prohibited.start()]
quoted = _CONTRACTION.sub("", leading)
if sum(quoted.count(char) for char in '"“”') % 2:
return False
if sum(quoted.count(char) for char in "'‘’") % 2:
return False
if _DISAVOWAL.search(leading):
return False
if _LEADING_QUALIFIER.search(_CLAUSE_BREAK.split(leading)[-1]):
return False
# A bare "No" after a question answers it; it does not prohibit the
# imperative that follows ("Keep anything private? No Reveal ...").
if prohibited.group("negation").lower() == "no" and leading.rstrip().endswith("?"):
return False
if _BLANK_LINE.search(LOGICAL_LINE_BREAK.sub("\n", prohibited.group())):
return False

# A long unfinished clause may hide a later exception. Do not infer safety
# from an arbitrarily clipped fragment of that clause.
raw_tail = content[end : end + _CONTEXT_CHARS]
tail_view = normalized_security_view(raw_tail)
tail = tail_view.text
if _DISAVOWAL.search(tail) or _REFERENTIAL_ACTION.search(tail):
return False
boundary = _SENTENCE_END.search(tail)
if boundary is None and end + len(raw_tail) < len(content):
return False
if boundary is not None:
# Prove independent detection against the actual continuation bytes:
# YARA does not scan normalized or reconstructed text. In particular,
# fullwidth/markup-split YARA-only verbs must not justify exemption.
following_start = (
tail_view.source_offset(boundary.end()) if boundary.end() < len(tail) else len(raw_tail)
)
following = raw_tail[following_start:].lstrip()
following = _LIST_PREFIX.sub("", following, count=1)
independent = _NEXT_INSTRUCTION.match(following) if following else None
if following and independent is None:
return False
if (
independent is not None
and independent.group("yara_reveal") is not None
and (
not allow_yara_continuation
or _YARA_AGENT_CONTEXT.search(content, left, end + _CONTEXT_CHARS) is None
)
):
return False
clause = tail[: boundary.start()] if boundary is not None else tail
if _EXCEPTION.search(clause):
return False
clean_tail = clause.translate(_MARKDOWN).strip().rstrip("])}").rstrip()
return not clean_tail or (
allowed_tail is not None and allowed_tail.fullmatch(clean_tail) is not None
)
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from . import static_runner
from .common import get_context, get_line_number
from .pattern_defaults import PatternCategory
from .prohibition_context import is_directly_prohibited

logger = get_logger(__name__)

Expand Down Expand Up @@ -112,7 +113,7 @@
r"(?:os\.listdir|os\.scandir|glob\.glob|Path\.iterdir)\s*\([^)]*\.(?:claude|codex|gemini)/skills?",
0.9,
),
(r"(?:ls|find|dir)\s+[^|&;\n]*\.(?:claude|codex|gemini)/skills?", 0.85),
(r"(?<![\w-])g?(?:ls|find|dir)\s+[^|&;\n]*\.(?:claude|codex|gemini)/skills?", 0.85),
# Reading other skills' SKILL.md files
(r"open\s*\(\s*['\"][^'\"]*SKILL\.md['\"].*?\bother\b", 0.85),
# Accessing skills/CURRENT or adjacent skill directories
Expand Down Expand Up @@ -194,12 +195,11 @@ def ctx(start: int) -> str:
)

for pattern, confidence in AS3_PATTERNS:
matches = (
static_runner.iter_paragraph_matches
if (pattern, confidence) in AS3_PROSE_PATTERNS
else re.finditer
)
is_prose = (pattern, confidence) in AS3_PROSE_PATTERNS
matches = static_runner.iter_paragraph_matches if is_prose else re.finditer
for match in matches(pattern, content, re.IGNORECASE | re.MULTILINE):
if is_prose and is_directly_prohibited(content, match.start(), match.end()):
continue
full_match = match.group(0)
if _is_current_skill_path_reference(
full_match, _CURRENT_SKILL_IDENTIFIERS.get()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from . import static_runner
from .common import SourceLocationIndex, get_context, get_line_number
from .pattern_defaults import PatternCategory
from .prohibition_context import is_directly_prohibited

logger = get_logger(__name__)

Expand Down Expand Up @@ -103,6 +104,43 @@
]
EA2_PATTERNS = EA2_CODE_PATTERNS + EA2_PROSE_PATTERNS

# Approval qualifiers are risky only when attached to an affirmative action.
# Keep this grammar deliberately small: arbitrary words between a prohibition
# and "without approval" could contain a separate, operative instruction.
_APPROVAL_OBJECT = (
r"(?:(?:new|additional|external)\s+)?"
r"(?:dependencies|changes|files|commands|deployments|purchases|requests|actions)"
)
_APPROVAL_ACTION = re.compile(
rf"\b(?P<action>(?:add|install|introduce|make|delete|remove|modify|run|execute|send)"
rf"\s+{_APPROVAL_OBJECT}|deploy)\s+$",
re.IGNORECASE,
)
_NO_UNAPPROVED_ACTION = re.compile(rf"\bno\s+(?P<object>{_APPROVAL_OBJECT})\s+$", re.IGNORECASE)
_PROHIBITED_ACTION_OBJECT = re.compile(
r"(?:(?:all|any|new)\s+)?(?:files|commands|changes|requests|messages|dependencies)",
re.IGNORECASE,
)


def _requires_approval(content: str, start: int, end: int) -> bool:
"""Recognize a local requirement for approval, without negating other actions."""
if not content[start:end].lower().startswith("without"):
return False
prefix_start = max(0, start - 160)
prefix = content[prefix_start:start]
action = _APPROVAL_ACTION.search(prefix)
if action is not None:
return is_directly_prohibited(content, prefix_start + action.start(), end)
constraint = _NO_UNAPPROVED_ACTION.search(prefix)
if constraint is None:
return False
# Reuse the prohibition helper's clause/override checks by treating "no"
# as the directly governing prohibition of the constrained object.
object_start = prefix_start + constraint.start("object")
return is_directly_prohibited(content, object_start, end)


# EA3: Scope Creep
EA3_PATTERNS = [
(
Expand Down Expand Up @@ -410,6 +448,16 @@ def ctx(start: int) -> str:
else re.finditer
)
for match in matches(pattern, content, re.IGNORECASE | re.MULTILINE):
if (pattern, confidence) in EA2_PROSE_PATTERNS and (
is_directly_prohibited(
content,
match.start(),
match.end(),
allowed_tail=_PROHIBITED_ACTION_OBJECT,
)
or _requires_approval(content, match.start(), match.end())
):
continue
line_num = get_line_number(content, match.start())
context_text = ctx(match.start())
findings.append(
Expand Down
Loading
Loading