Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions src/skillspector/nodes/analyzers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@
LINE_BREAK_CHARS = "\r\n\v\f\x1c\x1d\x1e\x85\u2028\u2029"
MAX_FINDING_CONTEXT_CHARS = 1_000

# Chinese-language static patterns (issue #953). Chinese has no word spaces,
# so a phrase is matched character by character with only separators allowed
# between characters: ordinary whitespace, the ideographic space (U+3000),
# and invisible separators (zero-width and bidi marks, BOM). Full-width
# punctuation (U+FF0C and friends are covered by the explicit list) and the
# particle "de" are allowed only between phrase segments, never inside a
# single word, so a separator cannot join two unrelated clauses into a word.
CHINESE_SEPARATOR = r"[\s\u3000\u200b-\u200f\ufeff]*"
CHINESE_GAP = r"[,。、;:!?\s\u3000\u200b-\u200f\ufeff的]*"


def chinese_spaced(phrase: str) -> str:
"""Return a regex fragment matching *phrase* with separators between characters.

*phrase* must be literal text (no regex metacharacters); every character
is escaped and joined with :data:`CHINESE_SEPARATOR`, so "忽略" also
matches "忽 略", "忽\u3000略", and "忽\u200b略".
"""
return CHINESE_SEPARATOR.join(re.escape(ch) for ch in phrase)


def make_dummy_finding(analyzer_id: str) -> Finding:
"""Create a deterministic dummy finding for a stub analyzer."""
Expand Down
105 changes: 104 additions & 1 deletion src/skillspector/nodes/analyzers/prohibition_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@
re.IGNORECASE,
)
_BLANK_LINE = re.compile(r"\n[ \t]*\n")
# Chinese direct prohibitions, for the new Chinese extraction surface in
# static_patterns_system_prompt_leakage (issue #953). This mirrors the
# English grammar above and is deliberately narrow: only a negation that
# immediately governs the matched action exempts it. The negation set is
# the plain prohibitions 请勿, 请不要, 不要, 不得, 切勿, and 勿, optionally
# preceded by an agent noun, 请, or a second-person pronoun, with only
# space-like separators (ordinary and ideographic spaces, zero-width and
# bidi marks) between the characters and before the action. A newline
# between negation and action does not govern, exactly as in English.
# Broader forms (严禁, 禁止, 不准, 不能) are not included: they are not
# proven by the reviewer sentence and some state capability, not a direct
# imperative prohibition.
_CHINESE_SEP = r"[ \t\u3000\u200b-\u200f\ufeff]*"
_CHINESE_NEGATION = rf"(?:勿|不{_CHINESE_SEP}要|不{_CHINESE_SEP}得|切{_CHINESE_SEP}勿)"
_CHINESE_DIRECT_PREFIX = re.compile(
r"(?:\A|(?<=[\n\r.!?;,。!?、]))[ \t\u3000]*"
r"(?:[-+][ \t\u3000]+|\d+[.)][ \t\u3000]+)?"
rf"(?:(?:助手|助理|模型){_CHINESE_SEP})?"
rf"(?:请{_CHINESE_SEP})?"
rf"(?:(?:你|您){_CHINESE_SEP})?"
rf"(?P<negation>{_CHINESE_NEGATION}){_CHINESE_SEP}\Z"
)
_CHINESE_SENTENCE_END = re.compile(rf"[.!?;。!?{LINE_BREAK_CHARS}]")
_CHINESE_CLAUSE_BREAK = re.compile(rf"[.!?;。!?{LINE_BREAK_CHARS}]")
_CHINESE_LEADING_QUALIFIER = re.compile(
r"(?:除非|如果|假如|只有|仅当|否则|当[^,。;:!?\n]{0,20}时|在[^,。;:!?\n]{0,40}?(?:情况|条件)下)"
)
_CHINESE_EXCEPTION = re.compile(r"(?:除非|但是|不过|然而|除外|如果|假如|只有|仅当|否则)")
_CHINESE_DISAVOWAL = re.compile(r"(?:忽略|无视|违背|撤销|取消|废除|不再适用|过时)")
_CHINESE_REFERENTIAL = re.compile(r"(?:输出它|透露它|泄露它|照做|这样做|按此执行)")
_CHINESE_FOLLOWING_OVERRIDE = re.compile(
r"(?:除非|但是|不过|然而|除外|否则|照做|输出它|透露它|现在就输出|立即输出|按此执行)"
)
_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)
Expand Down Expand Up @@ -114,6 +147,70 @@
)


def _is_chinese_directly_prohibited(
content: str,
start: int,
end: int,
allowed_tail: re.Pattern[str] | None,
) -> bool:
"""Whether a Chinese direct prohibition immediately governs this action.

Mirrors the English grammar in :func:`is_directly_prohibited`: the
negation must sit immediately before the matched action, a condition
or scope leading into it rejects the exemption, an exception in the
same clause rejects it, and a following sentence carrying an
exception or override marker rejects it. A negation elsewhere in
the sentence (for example governing a different verb) never exempts,
because the prefix pattern is anchored at the match start and allows
only space-like separators after the negation. 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 = _CHINESE_DIRECT_PREFIX.search(prefix)
if prohibited is None or (left and prohibited.start() == 0):
return False
leading = prefix[: prohibited.start()]
if sum(leading.count(char) for char in "“”") % 2:
return False
if sum(leading.count(char) for char in "‘’") % 2:
return False
if _CHINESE_DISAVOWAL.search(leading):
return False
if _CHINESE_LEADING_QUALIFIER.search(_CHINESE_CLAUSE_BREAK.split(leading)[-1]):
return False
if _BLANK_LINE.search(LOGICAL_LINE_BREAK.sub("\n", prohibited.group())):
return False
raw_tail = content[end : end + _CONTEXT_CHARS]
tail_view = normalized_security_view(raw_tail)
tail = tail_view.text
if _CHINESE_DISAVOWAL.search(tail) or _CHINESE_REFERENTIAL.search(tail):
return False
boundary = _CHINESE_SENTENCE_END.search(tail)
if boundary is None and end + len(raw_tail) < len(content):
return False
if boundary is not None:
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)
following_clause = (
_CHINESE_SENTENCE_END.split(following, maxsplit=1)[0] if following else ""
)
if _CHINESE_FOLLOWING_OVERRIDE.search(following_clause):
return False
clause = tail[: boundary.start()] if boundary is not None else tail
if _CHINESE_EXCEPTION.search(clause):
return False
clean_tail = clause.translate(_MARKDOWN).strip(" \t\u3000,、;:").rstrip("])}").strip()
return not clean_tail or (
allowed_tail is not None and allowed_tail.fullmatch(clean_tail) is not None
)


def is_directly_prohibited(
content: str,
start: int,
Expand Down Expand Up @@ -158,7 +255,13 @@ def is_directly_prohibited(
# unchanged. Retain all leading text for quote/reversal checks.
prefix = prefix[:prefix_boundary] + ";" + prefix[prefix_boundary + 1 :]
prohibited = _DIRECT_PREFIX.search(prefix)
if prohibited is None or (left and prohibited.start() == 0):
if prohibited is None:
# No English prohibition governs this match; a Chinese one may.
# An English prohibition that exists but was rejected above (for
# example by allow_clause_prefix scope checks) keeps its verdict
# and is not overridden by a Chinese check.
return _is_chinese_directly_prohibited(content, start, end, allowed_tail)
if left and prohibited.start() == 0:
return False
leading = prefix[: prohibited.start()]
quoted = _CONTRACTION.sub("", leading)
Expand Down
214 changes: 213 additions & 1 deletion src/skillspector/nodes/analyzers/static_patterns_anti_refusal.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@
from skillspector.state import AnalyzerNodeResponse, SkillspectorState

from . import static_runner
from .common import SourceLocationIndex, get_context, get_line_number, is_code_example
from .common import (
CHINESE_GAP,
SourceLocationIndex,
chinese_spaced,
get_context,
get_line_number,
is_code_example,
)
from .pattern_defaults import PatternCategory

logger = get_logger(__name__)
Expand Down Expand Up @@ -71,6 +78,60 @@
),
(r"\brespond\s+to\s+(?:every|all|any)\s+(?:request|question|prompt|query|command)\b", 0.6),
(r"\bno\s+request\s+is\s+(?:off[- ]limits|forbidden|too)\b", 0.85),
# Chinese-language equivalents (issue #953).
# The refusal pattern is split into adverb-present and adverb-absent
# variants on purpose. A leading optional group followed by a gap that
# matches whitespace lets the regex engine try the gap at every
# whitespace position and backtrack the whole run, which is quadratic
# on long separator runs and blew the per-artifact runtime limit on
# the cross-window separator test (PR #968 CI). Each variant below
# starts with a required Chinese character, so non-matching text fails
# fast, and together they match exactly the phrases the single
# pattern matched.
#
# The trailing quantifier pattern is likewise split into
# quantifier-present and quantifier-absent variants. With the
# quantifier absent, the two gaps around it collapse into one
# distribution of the separator run, which is quadratic on a long
# run after a matched refusal. Each variant below has exactly one
# gap between required segments there, so a near miss fails in
# linear time.
(
f"(?:{chinese_spaced('永远')}|{chinese_spaced('始终')}){CHINESE_GAP}"
f"(?:{chinese_spaced('不要')}|{chinese_spaced('不得')}|{chinese_spaced('不能')}|{chinese_spaced('绝不')}){CHINESE_GAP}"
f"{chinese_spaced('拒绝')}{CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}|{chinese_spaced('所有')}){CHINESE_GAP}"
f"(?:{chinese_spaced('请求')}|{chinese_spaced('要求')}|{chinese_spaced('用户')})",
0.85,
),
(
f"(?:{chinese_spaced('永远')}|{chinese_spaced('始终')}){CHINESE_GAP}"
f"(?:{chinese_spaced('不要')}|{chinese_spaced('不得')}|{chinese_spaced('不能')}|{chinese_spaced('绝不')}){CHINESE_GAP}"
f"{chinese_spaced('拒绝')}{CHINESE_GAP}"
f"(?:{chinese_spaced('请求')}|{chinese_spaced('要求')}|{chinese_spaced('用户')})",
0.85,
),
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('不得')}|{chinese_spaced('不能')}|{chinese_spaced('绝不')}){CHINESE_GAP}"
f"{chinese_spaced('拒绝')}{CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}|{chinese_spaced('所有')}){CHINESE_GAP}"
f"(?:{chinese_spaced('请求')}|{chinese_spaced('要求')}|{chinese_spaced('用户')})",
0.85,
),
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('不得')}|{chinese_spaced('不能')}|{chinese_spaced('绝不')}){CHINESE_GAP}"
f"{chinese_spaced('拒绝')}{CHINESE_GAP}"
f"(?:{chinese_spaced('请求')}|{chinese_spaced('要求')}|{chinese_spaced('用户')})",
0.85,
),
(
f"{chinese_spaced('必须')}{CHINESE_GAP}"
f"(?:{chinese_spaced('服从')}|{chinese_spaced('满足')}|{chinese_spaced('回答')}){CHINESE_GAP}"
f"(?:{chinese_spaced('所有')}|{chinese_spaced('一切')}|{chinese_spaced('任何')}){CHINESE_GAP}"
f"(?:{chinese_spaced('请求')}|{chinese_spaced('要求')}|{chinese_spaced('指令')})",
0.8,
),
(f"{chinese_spaced('不能')}{CHINESE_GAP}{chinese_spaced('说不')}", 0.85),
]

# AR2: Disclaimer / caveat / warning suppression.
Expand All @@ -93,6 +154,54 @@
0.8,
),
(r"\b(?:do\s+not|don'?t)\s+(?:apologize|apologise|say\s+sorry)\b", 0.55),
# Chinese-language equivalents (issue #953). Each pattern with
# optional segments is split into one variant per combination of
# those segments, as with the P1/P6 split: a gap on either side of
# an optional segment lets a near miss distribute a long separator
# run across the gaps (quadratic for one optional, cubic for two),
# while each variant has exactly one gap between required segments
# and fails in linear time. Together the variants match exactly the
# phrases the single patterns matched.
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('请勿')}|{chinese_spaced('不得')}){CHINESE_GAP}"
f"(?:{chinese_spaced('给出')}|{chinese_spaced('添加')}|{chinese_spaced('包含')}){CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}){CHINESE_GAP}"
f"(?:{chinese_spaced('警告')}|{chinese_spaced('免责声明')})",
0.8,
),
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('请勿')}|{chinese_spaced('不得')}){CHINESE_GAP}"
f"(?:{chinese_spaced('给出')}|{chinese_spaced('添加')}|{chinese_spaced('包含')}){CHINESE_GAP}"
f"(?:{chinese_spaced('警告')}|{chinese_spaced('免责声明')})",
0.8,
),
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('请勿')}|{chinese_spaced('不得')}){CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}){CHINESE_GAP}"
f"(?:{chinese_spaced('警告')}|{chinese_spaced('免责声明')})",
0.8,
),
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('请勿')}|{chinese_spaced('不得')}){CHINESE_GAP}"
f"(?:{chinese_spaced('警告')}|{chinese_spaced('免责声明')})",
0.8,
),
(
f"(?:{chinese_spaced('不要')}|{chinese_spaced('请勿')}){CHINESE_GAP}"
f"(?:{chinese_spaced('说教')}|{chinese_spaced('道德评判')}|{chinese_spaced('讲道理')})",
0.85,
),
(
f"(?:{chinese_spaced('没有')}|{chinese_spaced('无需')}){CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}){CHINESE_GAP}"
f"(?:{chinese_spaced('警告')}|{chinese_spaced('免责声明')})",
0.75,
),
(
f"(?:{chinese_spaced('没有')}|{chinese_spaced('无需')}){CHINESE_GAP}"
f"(?:{chinese_spaced('警告')}|{chinese_spaced('免责声明')})",
0.75,
),
]

# AR3: Safety / policy / restriction nullification (jailbreak framing).
Expand Down Expand Up @@ -125,6 +234,109 @@
r"safeguards?)\b",
0.8,
),
# Chinese-language equivalents (issue #953). The unrestricted-mode
# pattern requires an entry verb, so a feature description that merely
# names the mode does not match. As with AR2 above, every pattern
# with optional segments is split into one variant per combination
# of those segments, so a near miss cannot distribute a long
# separator run across the gaps around an absent optional segment.
# Together the variants match exactly the phrases the single
# patterns matched. The trailing optional constraint in the
# 不受 pattern stays optional: it follows a required noun,
# so a match ending at the noun succeeds at once and never
# backtracks over a separator run.
(
f"(?:{chinese_spaced('处于')}|{chinese_spaced('进入')}|{chinese_spaced('切换到')}|{chinese_spaced('开启')}){CHINESE_GAP}"
f"(?:{chinese_spaced('无限制')}|{chinese_spaced('不受限制')}|{chinese_spaced('没有限制')}|{chinese_spaced('无任何限制')}){CHINESE_GAP}"
f"(?:{chinese_spaced('模式')}|{chinese_spaced('状态')})",
0.9,
),
(
f"{chinese_spaced('绕过')}{CHINESE_GAP}"
f"(?:{chinese_spaced('所有')}|{chinese_spaced('全部')}){CHINESE_GAP}"
f"{chinese_spaced('安全')}{CHINESE_GAP}"
f"(?:{chinese_spaced('限制')}|{chinese_spaced('约束')}|{chinese_spaced('防护')}|{chinese_spaced('政策')}|{chinese_spaced('规则')}|{chinese_spaced('規則')})",
0.9,
),
(
f"{chinese_spaced('绕过')}{CHINESE_GAP}"
f"{chinese_spaced('安全')}{CHINESE_GAP}"
f"(?:{chinese_spaced('限制')}|{chinese_spaced('约束')}|{chinese_spaced('防护')}|{chinese_spaced('政策')}|{chinese_spaced('规则')}|{chinese_spaced('規則')})",
0.9,
),
(
f"(?:{chinese_spaced('你')}|{chinese_spaced('您')}){CHINESE_GAP}"
f"(?:{chinese_spaced('没有')}|{chinese_spaced('不受')}){CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}){CHINESE_GAP}"
f"(?:{chinese_spaced('限制')}|{chinese_spaced('约束')}|{chinese_spaced('规则')}|{chinese_spaced('規則')})",
0.9,
),
(
f"(?:{chinese_spaced('你')}|{chinese_spaced('您')}){CHINESE_GAP}"
f"(?:{chinese_spaced('没有')}|{chinese_spaced('不受')}){CHINESE_GAP}"
f"(?:{chinese_spaced('限制')}|{chinese_spaced('约束')}|{chinese_spaced('规则')}|{chinese_spaced('規則')})",
0.9,
),
(
f"{chinese_spaced('不受')}{CHINESE_GAP}"
f"(?:{chinese_spaced('任何')}){CHINESE_GAP}"
f"(?:{chinese_spaced('政策')}|{chinese_spaced('规则')}|{chinese_spaced('規則')}|{chinese_spaced('限制')}){CHINESE_GAP}"
f"(?:{chinese_spaced('约束')})?",
0.9,
),
(
f"{chinese_spaced('不受')}{CHINESE_GAP}"
f"(?:{chinese_spaced('政策')}|{chinese_spaced('规则')}|{chinese_spaced('規則')}|{chinese_spaced('限制')}){CHINESE_GAP}"
f"(?:{chinese_spaced('约束')})?",
0.9,
),
(
f"{chinese_spaced('忽略')}{CHINESE_GAP}"
f"(?:{chinese_spaced('你的')}|{chinese_spaced('您的')}){CHINESE_GAP}"
f"(?:{chinese_spaced('安全')}|{chinese_spaced('内容')}){CHINESE_GAP}"
f"(?:{chinese_spaced('政策')}|{chinese_spaced('准则')}|{chinese_spaced('规则')}|{chinese_spaced('規則')}|{chinese_spaced('限制')})",
0.9,
),
(
f"{chinese_spaced('忽略')}{CHINESE_GAP}"
f"(?:{chinese_spaced('你的')}|{chinese_spaced('您的')}){CHINESE_GAP}"
f"(?:{chinese_spaced('政策')}|{chinese_spaced('准则')}|{chinese_spaced('规则')}|{chinese_spaced('規則')}|{chinese_spaced('限制')})",
0.9,
),
(
f"{chinese_spaced('忽略')}{CHINESE_GAP}"
f"(?:{chinese_spaced('安全')}|{chinese_spaced('内容')}){CHINESE_GAP}"
f"(?:{chinese_spaced('政策')}|{chinese_spaced('准则')}|{chinese_spaced('规则')}|{chinese_spaced('規則')}|{chinese_spaced('限制')})",
0.9,
),
(
f"{chinese_spaced('忽略')}{CHINESE_GAP}"
f"(?:{chinese_spaced('政策')}|{chinese_spaced('准则')}|{chinese_spaced('规则')}|{chinese_spaced('規則')}|{chinese_spaced('限制')})",
0.9,
),
(
f"{chinese_spaced('解除')}{CHINESE_GAP}"
f"(?:{chinese_spaced('所有')}|{chinese_spaced('全部')}){CHINESE_GAP}"
f"(?:{chinese_spaced('限制')}|{chinese_spaced('约束')}|{chinese_spaced('防护')})",
0.85,
),
(
f"{chinese_spaced('解除')}{CHINESE_GAP}"
f"(?:{chinese_spaced('限制')}|{chinese_spaced('约束')}|{chinese_spaced('防护')})",
0.85,
),
(
f"(?:{chinese_spaced('开发者')}|{chinese_spaced('越狱')}|{chinese_spaced('管理员')}){CHINESE_GAP}"
f"{chinese_spaced('模式')}{CHINESE_GAP}{chinese_spaced('已')}{CHINESE_GAP}"
f"(?:{chinese_spaced('开启')}|{chinese_spaced('启用')}|{chinese_spaced('激活')})",
0.75,
),
(
f"(?:{chinese_spaced('开发者')}|{chinese_spaced('越狱')}|{chinese_spaced('管理员')}){CHINESE_GAP}"
f"{chinese_spaced('模式')}{CHINESE_GAP}"
f"(?:{chinese_spaced('开启')}|{chinese_spaced('启用')}|{chinese_spaced('激活')})",
0.75,
),
]

_RULES = [("AR1", AR1_PATTERNS), ("AR2", AR2_PATTERNS), ("AR3", AR3_PATTERNS)]
Expand Down
Loading
Loading