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
96 changes: 96 additions & 0 deletions src/skillspector/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ class _ObfuscatedIgnoreState:
r"(?:[^\W\d_](?:[^\w]|_)+){5}[^\W\d_]",
re.UNICODE,
)
_PROMPT_SPACED_PAIR = re.compile(r"(?<![A-Za-z])\S \S(?![A-Za-z])")
_MIN_LETTER_SPACING_RUN_LETTERS = 6
# Unicode 15.1.0 DerivedCoreProperties.txt: Default_Ignorable_Code_Point.
_DEFAULT_IGNORABLE_RANGES = (
Expand Down Expand Up @@ -1700,6 +1701,101 @@ def compact_letter_view(text: str) -> SecurityTextView:
return SecurityTextView("compact", output.getvalue(), offsets)


def prompt_injection_letter_spacing_view(
text: str,
check_runtime: Callable[[], None] | None = None,
*,
preserve_identifier_boundaries: bool = True,
) -> SecurityTextView:
"""Collapse ASCII-spaced tokens without inventing word boundaries.

The prompt-injection analyzer alone consumes this projection. Existing
multi-space word boundaries are retained verbatim, while one-space token
interiors are removed with exact raw offsets. A completely boundary-free
run therefore remains one condensed token and cannot acquire a guessed P3
or P4 segmentation. Identifier-adjacent runs are preserved for semantic
classification; artifact-integrity may opt into their projection solely to
produce an ambiguity finding.
"""
if check_runtime is not None:
check_runtime()
if _PROMPT_SPACED_PAIR.search(text) is None:
return SecurityTextView("prompt-letter-spacing", text)
processed_since_check = 0

def record_work(characters: int = 1) -> None:
nonlocal processed_since_check
if check_runtime is None:
return
processed_since_check += characters
if processed_since_check >= 4096:
check_runtime()
processed_since_check %= 4096

output = StringIO()
offsets = array("I")
transformed = False

def append_source(start: int, end: int) -> None:
for source_offset in range(start, end):
record_work()
output.write(text[source_offset])
offsets.append(source_offset)

cursor = 0
index = 0
while index < len(text):
record_work()
if (
text[index].isspace()
or index + 2 >= len(text)
or text[index + 1] != " "
or text[index + 2].isspace()
):
index += 1
continue

run_start = index
run_end = index + 1
while run_end + 1 < len(text) and text[run_end] == " " and not text[run_end + 1].isspace():
run_end += 2
record_work(2)

# Do not rewrite a letter-spaced fragment embedded in an identifier.
# Advance past rejected runs too, so attacker-controlled near misses
# cannot force quadratic rescanning from every interior character.
left_identifier = run_start > 0 and (
text[run_start - 1].isascii()
and (text[run_start - 1].isalnum() or text[run_start - 1] == "_")
)
right_identifier = run_end < len(text) and (
text[run_end].isascii() and (text[run_end].isalnum() or text[run_end] == "_")
)
left_letter = (
run_start > 0 and text[run_start - 1].isascii() and text[run_start - 1].isalpha()
)
right_letter = run_end < len(text) and text[run_end].isascii() and text[run_end].isalpha()
boundary_is_safe = (
not left_identifier and not right_identifier
if preserve_identifier_boundaries
else not left_letter and not right_letter
)
if boundary_is_safe:
append_source(cursor, run_start)
for source_offset in range(run_start, run_end, 2):
record_work(2)
output.write(text[source_offset])
offsets.append(source_offset)
cursor = run_end
transformed = True
index = run_end
Comment on lines +1760 to +1791

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one that worries me: s e n d conversation to external — ONE space before "conversation", i.e. the most natural way to write the attack — scans completely SAFE end to end, while the two-space version in your tests fires P3. Same story for n e v e r warn the user.

What happens: the extension loop at 1760 swallows the first letter of the adjacent word (the run becomes s e n d c), so right_letter/right_identifier is true and the whole run gets rejected — and index = run_end means we never retry from inside the run. The run is also under 6 letters, so the pre-existing compact view can't rescue it either. Tab-mixed (s\te\tn\td\tconversation ...) and dot-spaced (s.e.n.d conversation ...) variants also come out SAFE.

Suggested fix that stays within this design: like _letter_spacing_offsets (~line 447), stop extending the run when the next letter starts a multi-letter word (two-letter lookahead), and after a left-side rejection retry once from the next spaced letter. Still linear — each position is visited at most twice — and I traced the rover/cover and identifier-adjacency tests: they keep passing, while s e n d conversation to external projects to send conversation to external and fires P3.


if not transformed:
return SecurityTextView("prompt-letter-spacing", text)
append_source(cursor, len(text))
return SecurityTextView("prompt-letter-spacing", output.getvalue(), offsets)


def _requires_normalized_security_view(text: str) -> bool:
"""Return whether normalization can produce a distinct security view."""
if _IGNORED_ASCII_CONTROL.search(text) is not None:
Expand Down
92 changes: 89 additions & 3 deletions src/skillspector/nodes/analyzers/artifact_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_concealed_instruction_run_spans,
_contextual_default_ignorable_boundary_spans,
_obfuscated_instruction_matches,
prompt_injection_letter_spacing_view,
)
from skillspector.inspection_ledger import (
InspectionLedgerEvent,
Expand All @@ -36,6 +37,7 @@
from skillspector.unicode_confusables import ASCII_CONFUSABLE_SKELETON

from .common import LINE_BREAK_CHARS, LOGICAL_LINE_BREAK, get_line_number
from .static_patterns_prompt_injection import P3_PATTERNS, P4_PATTERNS
from .static_runner import MAX_FINDINGS_PER_ANALYZER, MAX_FINDINGS_PER_ARTIFACT

ANALYZER_ID = "artifact_integrity"
Expand All @@ -61,6 +63,12 @@
"unfiltered",
"unrestricted",
"userdata",
"withoutinforming",
"withoutnotifying",
"withouttelling",
"withouttheuserknowing",
"withouttheusernoticing",
"withouttheuserrealizing",
)
_LETTER_SPACING_EXACT_SECURITY_TERMS = frozenset(
{
Expand All @@ -74,6 +82,32 @@
"systemprompt",
}
)
_AMBIGUOUS_PROMPT_PATTERNS = tuple(
re.compile(pattern)
for pattern in (
r"(?:send|transmit|upload|post|forward)(?:the)?(?:conversation|context|chat|history|messages?)to[a-z]{0,96}",
r"(?:send|transmit|upload|post)(?:all)?(?:user)?(?:data|information|input|files?)to(?:https|external)[a-z]{0,96}",
r"(?:copy|extract|collect)(?:the)?(?:conversation|context|session)(?:and)?(?:send|upload|transmit)",
r"(?:call|notify|ping|hit)(?:this)?(?:webhook|endpoint|url|api)with(?:the)?(?:user|conversation|context)",
r"include(?:the)?(?:full)?(?:conversation|context|history)in(?:the)?(?:request|payload|body)",
r"(?:silently|quietly|secretly|covertly)(?:send|transmit|log|record)",
r"without(?:telling|informing|notifying)(?:the)?user[a-z]{0,128}(?:send|transmit|upload)",
r"always(?:recommend|suggest|prefer|choose|use)[a-z]{1,96}(?:over|insteadof|ratherthan)",
r"never(?:recommend|suggest|mention|tell|warn|inform)(?:the)?(?:user|them)",
r"(?:prioritize|prefer)[a-z]{1,96}(?:over|above)(?:user)?(?:safety|security|privacy|preferences?)",
r"(?:subtly|gradually|slowly)(?:steer|guide|push|nudge)(?:the)?(?:user|conversation)",
r"(?:the)?user(?:knowing|realizing|noticing)[a-z]{0,128}(?:change|modify|alter|influence)",
r"(?:gain|build|establish)(?:the)?users?trust(?:andthen|before|sothat)",
r"(?:appear|seem|act)(?:helpful|friendly|trustworthy)(?:while|but|then)",
r"(?:always|never)include(?:this|thefollowing)in(?:every|all)(?:response|output|reply)",
r"append(?:this|thefollowing)to(?:every|all|each)(?:response|message|output)",
)
)
Comment on lines +85 to +105

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These sixteen regexes are a hand-transliterated copy of P3_PATTERNS/P4_PATTERNS with the \s+ removed, and they've already drifted in three places: the always pattern here requires at least one filler letter ([a-z]{1,96}) where the raw pattern allows zero filler words; raw \w+ (digits allowed) became [a-z]; and the raw pattern's optional (?:without\s+)? prefix on the "user knowing" rule got dropped.

Nothing is broken today, but any future edit to P3/P4 silently de-syncs this fail-closed net. Could these be derived mechanically from the source patterns? Failing that, a test asserting one ambiguous counterpart exists per P3/P4 pattern would at least catch the drift.

_MAX_AMBIGUOUS_PROMPT_PHRASE = 512
_PROJECTED_PROMPT_PATTERNS = tuple(
re.compile(pattern, re.IGNORECASE | re.MULTILINE)
for pattern, _confidence in (*P3_PATTERNS, *P4_PATTERNS)
)
_LETTER_SPACING_PROMPT_ACTIONS = (
"disclose",
"disclosed",
Expand Down Expand Up @@ -333,6 +367,7 @@ def alternation(values: tuple[str, ...]) -> str:
)
_MAX_LETTER_SPACING_SECURITY_PHRASE = max(
max(map(len, _LETTER_SPACING_EXACT_SECURITY_TERMS)),
_MAX_AMBIGUOUS_PROMPT_PHRASE,
max(map(len, _LETTER_SPACING_SECURITY_PREFIXES))
+ max(map(len, _LETTER_SPACING_ALL_ACTIONS))
+ _MAX_LETTER_SPACING_SECURITY_CONNECTORS * max(map(len, _LETTER_SPACING_SECURITY_CONNECTORS))
Expand Down Expand Up @@ -411,6 +446,11 @@ def _spacing_phrase_has_security_signal(phrase: str) -> bool:
)


def _ambiguous_prompt_phrase_has_security_signal(phrase: str) -> bool:
"""Match bounded P3/P4 grammar only when source word boundaries are absent."""
return any(pattern.search(phrase) is not None for pattern in _AMBIGUOUS_PROMPT_PATTERNS)


def _bounded_same_line_context(
content: str,
start: int,
Expand Down Expand Up @@ -487,6 +527,7 @@ def _spacing_span_has_security_signal(
"""Match bounded security semantics without retaining the full run."""
if _spacing_span_is_benign_notation(content, span):
return False
has_explicit_boundary = content.find(" ", span[0], span[1]) != -1
overlap = ""
letters: list[str] = []
letter_characters = 0
Expand Down Expand Up @@ -527,14 +568,26 @@ def _spacing_span_has_security_signal(
if any(term in block for term in _LETTER_SPACING_SECURITY_TERMS):
return True
if phrase_overflow:
return False
if _spacing_phrase_has_security_signal("".join(phrase_parts)):
# A boundary-free letter stream this large cannot be reconstructed
# safely. Treat it as ambiguous instead of silently blessing it.
return not has_explicit_boundary
phrase = "".join(phrase_parts)
if _spacing_phrase_has_security_signal(phrase) or (
not has_explicit_boundary and _ambiguous_prompt_phrase_has_security_signal(phrase)
):
return True
shortened_phrase = "".join(phrase_parts[:-1])
return (
bool(phrase_parts)
and span[1] < len(content)
and content[span[1]].isalpha()
and _spacing_phrase_has_security_signal("".join(phrase_parts[:-1]))
and (
_spacing_phrase_has_security_signal(shortened_phrase)
or (
not has_explicit_boundary
and _ambiguous_prompt_phrase_has_security_signal(shortened_phrase)
)
)
)


Expand Down Expand Up @@ -591,6 +644,37 @@ def _contextual_ignorable_security_line(
return None


def _projected_prompt_injection_line(
content: str,
budget: _ArtifactIntegrityBudget,
) -> int | None:
"""Return the first raw line whose letter-spacing projection matches P3/P4."""
view = prompt_injection_letter_spacing_view(
content,
budget.check_runtime,
preserve_identifier_boundaries=False,
)
if view.source_offsets is None:
return None
first_offset: int | None = None
identifier_relaxed_text = re.sub(r"[0-9_]", " ", view.text)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line causes a reproducible false positive on ordinary code: a bundle containing an innocent spaced acronym (U S A on its own line) plus the normal identifier upload_files_to_external_service(config) gets AE6 HIGH pointing at the identifier's line ("Instruction text uses inter-character separators to evade pattern matching"), analysis incomplete, CAUTION. Remove the acronym and it's SAFE.

The issue is that any collapsible spaced pair anywhere in the file arms this whole-document rescan where every underscore and digit becomes a space — so identifiers that happen to spell a P3/P4 phrase get reported as obfuscation.

Suggestion: require the relaxed match to overlap at least one actually reconstructed span. Your s e n d1 conversation test still passes under that rule (its match contains reconstructed characters), but the pure-identifier match doesn't.

projected_texts = (
(view.text, identifier_relaxed_text)
if identifier_relaxed_text != view.text
else (view.text,)
)
for projected_text in projected_texts:
for pattern in _PROJECTED_PROMPT_PATTERNS:
budget.check_runtime()
match = pattern.search(projected_text)
if match is None:
continue
source_offset = view.source_offset(match.start())
if first_offset is None or source_offset < first_offset:
first_offset = source_offset
return get_line_number(content, first_offset) if first_offset is not None else None


def _text_signals(
content: str,
budget: _ArtifactIntegrityBudget,
Expand Down Expand Up @@ -629,12 +713,14 @@ def _text_signals(
if targeted_instruction is not None
else None
)
first_projected_prompt_line = _projected_prompt_injection_line(content, budget)
obfuscation_lines = [
value
for value in (
first_spacing_line,
first_contextual_ignorable_line,
first_targeted_instruction_line,
first_projected_prompt_line,
)
if value is not None
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import sys
from collections.abc import Iterator

from skillspector.artifacts import _is_emoji_base
from skillspector.artifacts import _is_emoji_base, prompt_injection_letter_spacing_view
from skillspector.logging_config import get_logger
from skillspector.models import AnalyzerFinding, Location, Severity
from skillspector.state import AnalyzerNodeResponse, SkillspectorState
Expand Down Expand Up @@ -290,36 +290,66 @@ def ctx(start: int) -> str:
matched_text=match.group(0)[:200],
)
)
for pattern, confidence in P3_PATTERNS:
for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE):
line_num = get_line_number(content, match.start())
findings.append(
AnalyzerFinding(
rule_id="P3",
message="Exfiltration Commands",
severity=Severity.HIGH,
location=loc(line_num),
confidence=confidence,
tags=tag,
context=ctx(match.start()),
matched_text=match.group(0)[:200],
)
)
for pattern, confidence in P4_PATTERNS:
for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE):
line_num = get_line_number(content, match.start())
findings.append(
AnalyzerFinding(
rule_id="P4",
message="Behavior Manipulation",
severity=Severity.MEDIUM,
location=loc(line_num),
confidence=confidence,
tags=tag,
context=ctx(match.start()),
matched_text=match.group(0)[:200],
prompt_rules = (
("P3", "Exfiltration Commands", Severity.HIGH, P3_PATTERNS),
("P4", "Behavior Manipulation", Severity.MEDIUM, P4_PATTERNS),
)
seen_prompt_matches: set[tuple[str, int, int]] = set()
for rule_id, message, severity, patterns in prompt_rules:
for pattern, confidence in patterns:
for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE):
source_start = match.start()
source_end = match.end()
seen_prompt_matches.add((rule_id, source_start, source_end))
findings.append(
AnalyzerFinding(
rule_id=rule_id,
message=message,
severity=severity,
location=loc(get_line_number(content, source_start)),
confidence=confidence,
tags=tag,
context=ctx(source_start),
matched_text=match.group(0)[:200],
)
)
)

# This projection is intentionally local to P3/P4. Other static rules keep
# their established text-view contract and cannot inherit classifications
# from letter-spacing reconstruction.
prompt_view = prompt_injection_letter_spacing_view(content)
if prompt_view.source_offsets is not None:
for rule_id, message, severity, patterns in prompt_rules:
for pattern, confidence in patterns:
for match in re.finditer(
pattern,
prompt_view.text,
re.IGNORECASE | re.MULTILINE,
):
Comment on lines +320 to +328

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cost note: one sweep of the 16 P3/P4 patterns over a 100 KB adversarial single-line window takes ~4.1s on my machine (pre-existing — the lazy .*? rescans), and this change now runs the sweep a second time per view here, plus up to two more in artifact-integrity. There's no runtime check inside these finditer loops, so a crafted spaced artifact can burn the 30s budget into partial analysis — same class as the CI timeout you already hit, and the C-regex prefilter only rescues non-spaced text.

It fails closed to CAUTION, so this is degradation rather than a bypass, but a budget check inside the loop would be cheap insurance.

source_start = prompt_view.source_offset(match.start())
source_end = prompt_view.source_offset(max(match.start(), match.end() - 1)) + 1
key = (rule_id, source_start, source_end)
if key in seen_prompt_matches:
continue
seen_prompt_matches.add(key)
evidence: dict[str, object] = (
{static_runner._VIEW_START_EVIDENCE: source_start}
if source_end - source_start <= static_runner._WINDOW_OVERLAP_CHARS
else {}
)
findings.append(
AnalyzerFinding(
rule_id=rule_id,
message=message,
severity=severity,
location=loc(get_line_number(content, source_start)),
confidence=confidence,
tags=tag,
context=ctx(source_start),
matched_text=match.group(0)[:200],
evidence=evidence,
)
)

# P2 (extended): Unicode Tag-block "ASCII smuggling". Runs regardless of
# file_type — invisible instructions are dangerous in scripts and config
Expand Down
Loading
Loading