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
144 changes: 144 additions & 0 deletions docs/scan-completeness.md

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions src/skillspector/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,14 @@ class _ObfuscatedIgnoreState:
_LOGICAL_LINE_BREAK_CHARACTERS = frozenset(
{"\r", "\n", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"}
)
_MULTILINE_PROMPT_SPACING_PAIR = re.compile(
r"(?<!\w)[^\W\d_]"
r"(?:[^\S\r\n\v\f\x1c-\x1e\x85\u2028\u2029]*"
r"(?:\r\n|[\r\n\v\f\x1c-\x1e\x85\u2028\u2029])"
r"[^\S\r\n\v\f\x1c-\x1e\x85\u2028\u2029]*"
r"|[^\S\r\n\v\f\x1c-\x1e\x85\u2028\u2029])"
r"[^\W\d_](?!\w)"
)
_REMOVE_ALLOWED_FORMAT_CHARACTERS = str.maketrans("", "", "".join(_ALLOWED_FORMAT_CHARS))


Expand Down Expand Up @@ -1958,6 +1966,66 @@ def append_source(start: int, end: int) -> None:
)


def multiline_prompt_injection_view(
text: str,
check_runtime: Callable[[], None] | None = None,
) -> SecurityTextView:
"""Project isolated letter lines for ambiguity detection, never classification.

One logical line break (optionally indented), or one horizontal space,
between alphabetic singleton tokens is removed. Paragraphs, list markers,
code punctuation, ordinary words and wider word gaps remain intact. Raw offsets and
removed-gap provenance let artifact-integrity attribute an unresolved
P3/P4-shaped instruction without treating this as semantic reconstruction.
"""
if check_runtime is not None:
check_runtime()
match = _MULTILINE_PROMPT_SPACING_PAIR.search(text)
if match is None:
return SecurityTextView("multiline-prompt-spacing", text)

output = StringIO()
offsets = array("I")
reconstructions: list[SecurityTextReconstruction] = []
cursor = 0
checked_offset = 0

def record_work(source_offset: int) -> None:
nonlocal checked_offset
if check_runtime is not None and source_offset - checked_offset >= 4096:
check_runtime()
checked_offset = source_offset

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

while match is not None:
run_start = match.start()
append_source(cursor, run_start)
derived_start = len(offsets)
output.write(text[run_start])
offsets.append(run_start)
while match is not None:
last_letter = match.end() - 1
record_work(last_letter)
output.write(text[last_letter])
offsets.append(last_letter)
match = _MULTILINE_PROMPT_SPACING_PAIR.match(text, last_letter)
cursor = last_letter + 1
reconstructions.append(
SecurityTextReconstruction(derived_start, len(offsets), run_start, cursor)
)
match = _MULTILINE_PROMPT_SPACING_PAIR.search(text, cursor)

append_source(cursor, len(text))
return SecurityTextView(
"multiline-prompt-spacing", output.getvalue(), offsets, tuple(reconstructions)
)


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
5 changes: 5 additions & 0 deletions src/skillspector/input_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ class InputHandler:
def __init__(self, transitive_budget: object | None = None) -> None:
self._temp_dir: Path | None = None
self._transitive_budget = transitive_budget
self.primary_file_path: str | None = None

def resolve(self, input_path: str) -> tuple[Path, str]:
"""
Expand All @@ -738,6 +739,7 @@ def resolve(self, input_path: str) -> tuple[Path, str]:
FileNotFoundError: If local path doesn't exist.
"""
input_path = input_path.strip()
self.primary_file_path = None

if self._is_git_url(input_path):
return self._clone_git(input_path), "git"
Expand Down Expand Up @@ -1209,6 +1211,7 @@ def _download_file(self, url: str) -> Path:
return self._extract_zip(zip_path)
file_path = temp_dir / filename
download_path.replace(file_path)
self.primary_file_path = filename
return temp_dir

def _download_transitive_file(self, url: str) -> Path:
Expand All @@ -1231,6 +1234,7 @@ def _download_transitive_file(self, url: str) -> Path:
zip_path.write_bytes(content)
return self._extract_zip(zip_path)
(temp_dir / filename).write_bytes(content)
self.primary_file_path = filename
return temp_dir

def _download_with_redirect_validation(self, url: str) -> tuple[dict[str, str], str, bytes]:
Expand Down Expand Up @@ -1459,4 +1463,5 @@ def _wrap_single_file(self, file_path: Path) -> Path:
except BaseException:
dest.unlink(missing_ok=True)
raise
self.primary_file_path = file_path.name
return temp_dir
5 changes: 5 additions & 0 deletions src/skillspector/inspection_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class LedgerReason(StrEnum):
ARCHIVE_TIME_LIMIT = "archive_time_limit"
VCS_METADATA = "vcs_metadata"
OPAQUE_CONTENT = "opaque_content"
UNSUPPORTED_PRIMARY_CONTENT = "unsupported_primary_content"
REFERENCED_UNINSPECTED = "referenced_uninspected"
REFERENCE_EXTRACTION_LIMIT = "reference_extraction_limit"
REFERENCE_UNRESOLVED = "reference_unresolved"
Expand Down Expand Up @@ -165,6 +166,10 @@ class LedgerReason(StrEnum):
"VCS object and history metadata is outside the bounded artifact inspection profile."
),
LedgerReason.OPAQUE_CONTENT: "Artifact contents could not be fully interpreted.",
LedgerReason.UNSUPPORTED_PRIMARY_CONTENT: (
"The requested file or primary instructions could not be interpreted. "
"Provide UTF-8 text, a supported ZIP, or an extracted directory instead."
),
LedgerReason.REFERENCED_UNINSPECTED: ("A referenced artifact was not completely inspected."),
LedgerReason.REFERENCE_EXTRACTION_LIMIT: (
"Reference extraction reached an explicit resource bound before completion."
Expand Down
106 changes: 105 additions & 1 deletion src/skillspector/nodes/analyzers/artifact_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@
from collections.abc import Iterator
from dataclasses import dataclass, field

import regex # type: ignore[import-untyped]

from skillspector.artifacts import (
ContentKind,
SecurityTextView,
_concealed_instruction_run_spans,
_contextual_default_ignorable_boundary_spans,
_obfuscated_instruction_matches,
multiline_prompt_injection_view,
prompt_injection_letter_spacing_view,
)
from skillspector.inspection_ledger import (
Expand Down Expand Up @@ -94,6 +97,18 @@
_PROJECTED_PROMPT_PATTERNS = tuple(
pattern for pattern, _confidence in (*COMPILED_P3_PATTERNS, *COMPILED_P4_PATTERNS)
)
# Removing line breaks can give the existing wildcard patterns a much longer
# search space. Interrupt the regex itself, not just work between matches.
_MULTILINE_PROMPT_PATTERN_SECONDS = 0.25
_MULTILINE_PROMPT_PATTERNS = tuple(
regex.compile(pattern.pattern, regex.ASCII | regex.IGNORECASE | regex.MULTILINE)
for pattern in _PROJECTED_PROMPT_PATTERNS
)
_PROMPT_ASCII_CASE_ALIASES = {"\u0130": "i", "\u0131": "i", "\u017f": "s", "\u212a": "k"}
_PROMPT_EXTRA_ASCII_WHITESPACE = "\x1c\x1d\x1e\x1f"
_PROMPT_ASCII_WHITESPACE_TRANSLATION = str.maketrans(
dict.fromkeys(_PROMPT_EXTRA_ASCII_WHITESPACE, " ")
)
_LETTER_SPACING_PROMPT_ACTIONS = (
"disclose",
"disclosed",
Expand Down Expand Up @@ -650,7 +665,7 @@ def _projected_prompt_injection_line(
preserve_identifier_boundaries=False,
)
if view.source_offsets is None:
return None
return _multiline_prompt_injection_line(content, budget)
first_offset: int | None = None
identifier_relaxed_text = view.text.translate(_IDENTIFIER_RELAXATION)
projected_texts = (
Expand Down Expand Up @@ -695,6 +710,95 @@ def _projected_prompt_injection_line(
source_offset = join_points[point_index][1]
if first_offset is None or source_offset < first_offset:
first_offset = source_offset
if first_offset is not None:
return get_line_number(content, first_offset)
return _multiline_prompt_injection_line(content, budget)


def _multiline_prompt_matching_text(text: str, budget: _ArtifactIntegrityBudget) -> str:
"""Preserve Python ``re`` semantics in the timeout engine's ASCII alphabet.

Current P3/P4 grammar has ASCII literals, word/space classes and wildcards;
neither ``0`` nor ``~`` is a literal. Keep one character per source character:
Python's word members become ``0``, whitespace becomes a space, and other
non-ASCII characters become ``~``. The four Unicode aliases of ASCII letters
under Python IGNORECASE retain their corresponding letters. Literal newlines
stay unchanged, so wildcard boundaries and every match offset are preserved.
This is only a matching alphabet, never a replacement source/evidence view.
"""
budget.check_runtime()
if text.isascii():
if not any(character in text for character in _PROMPT_EXTRA_ASCII_WHITESPACE):
return text
return text.translate(_PROMPT_ASCII_WHITESPACE_TRANSLATION)

parts: list[str] = []
for start in range(0, len(text), _RUNTIME_CHECK_INTERVAL_CHARS):
budget.check_runtime()
characters: list[str] = []
for character in text[start : start + _RUNTIME_CHECK_INTERVAL_CHARS]:
if character in _PROMPT_EXTRA_ASCII_WHITESPACE:
characters.append(" ")
elif character.isascii():
characters.append(character)
elif character in _PROMPT_ASCII_CASE_ALIASES:
characters.append(_PROMPT_ASCII_CASE_ALIASES[character])
elif character.isspace():
characters.append(" ")
else:
characters.append("0" if character.isalnum() else "~")
parts.append("".join(characters))
return "".join(parts)


def _multiline_prompt_injection_line(
content: str,
budget: _ArtifactIntegrityBudget,
) -> int | None:
"""Fail closed for prompt-shaped singleton lines without flattening prose."""
view = multiline_prompt_injection_view(content, budget.check_runtime)
if view.source_offsets is None:
return None
matching_text = _multiline_prompt_matching_text(view.text, budget)
first_offset: int | None = None
for pattern in _MULTILINE_PROMPT_PATTERNS:
budget.check_runtime()
remaining = transitive_remaining_seconds(budget.state)
timeout = _MULTILINE_PROMPT_PATTERN_SECONDS
if remaining is not None:
timeout = min(timeout, max(0.0, remaining))
started_at = time.monotonic()
reconstruction_index = 0
try:
for match in pattern.finditer(matching_text, timeout=timeout):

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.

Could we avoid spurious timeouts during parallel scans here? The new projection also activates on ordinary contractions such as it's a, and regex.finditer() releases the GIL by default (documented behavior). On HEAD 183fd554, a 6.8 KB prose document that is complete on the base becomes partial in both source and installed-wheel scans, with runtime_limit on this file and the remaining files. The first pattern takes under 0.14 ms alone but times out after about 320 ms in the normal parallel graph. Changing only this call to concurrent=False in a diagnostic control restores complete coverage without raising the timeout.

An independently generated benign fixture also returned strict CLI exit 1, zero coverage and no findings:

text = "# Reading notes\n\n" + (
    "It's a short book about a village library. The chapter describes shelves, windows, "
    "and reading tables. A visitor returns a borrowed volume and reads the next chapter.\n\n"
) * 40
# Put text in SKILL.md and notes.md, and json.dumps({"examples": [text]}) in examples.json.
# Run: skillspector scan <directory> --no-llm --format json --fail-on-incomplete

This is scheduling-sensitive: the same generated fixture completed in an MCP call. Please retain interruptible matching while preventing ordinary parallel analyzer activity from producing these false partial results, and cover this through the real parallel scan graph. Tested with CPython 3.12.13 on Linux arm64, with 2 CPUs and 4 GiB RAM.

budget.check_runtime()
# Matches and reconstruction spans are both ordered. Advance
# once per span, including ordinary matches before a spaced
# instruction, instead of rescanning all provenance per match.
while (
reconstruction_index < len(view.reconstructions)
and view.reconstructions[reconstruction_index].derived_end <= match.start() + 1
):
budget.check_runtime()
reconstruction_index += 1
if reconstruction_index == len(view.reconstructions):
break
reconstruction = view.reconstructions[reconstruction_index]
right = max(match.start() + 1, reconstruction.derived_start + 1)
if right < min(match.end(), reconstruction.derived_end):
source_offset = view.source_offset(right - 1) + 1
if first_offset is None or source_offset < first_offset:
first_offset = source_offset
# Later matches cannot precede this pattern's first gap.
break
except TimeoutError as exc:
raise _ArtifactIntegrityResourceLimitError(
LedgerReason.RUNTIME_LIMIT,
{
"observed_seconds": max(0.0, time.monotonic() - started_at),
"limit_seconds": timeout,
},
) from exc
return get_line_number(content, first_offset) if first_offset is not None else None


Expand Down
83 changes: 83 additions & 0 deletions src/skillspector/nodes/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import json
import os
import re
import tarfile
from collections.abc import Callable, Mapping
from pathlib import Path, PurePosixPath
from stat import S_ISREG
Expand Down Expand Up @@ -2507,6 +2508,49 @@ def _check_runtime() -> None:
return {}


def _unsupported_primary_bytes(artifact: ArtifactRecord, data: bytes) -> bool:
"""Recognize opaque primary content without opening or expanding containers.

ZIPs are handled separately by bounded nested inspection. Other archive
headers and UTF-16/32 instructions must not count as decoded source text,
even when their bytes happen to be valid UTF-8 (for example an ASCII TAR).
"""
split_utf8 = False
if not artifact["decodable"] and artifact["size_bytes"] > len(data):
# Only an unfinished trailing code point is explained by truncation.
# An invalid sequence earlier in the cached prefix is still unsupported.
try:
data.decode("utf-8")
except UnicodeDecodeError as exc:
split_utf8 = exc.reason == "unexpected end of data"
sample = data[:512]
try:
# Validates one fixed-size header (including its checksum), never
# enumerates members or expands compressed/archive contents.
tarfile.TarInfo.frombuf(sample, "utf-8", "surrogateescape")
except tarfile.HeaderError:
is_tar = False
else:
is_tar = True
is_bzip2 = (
sample.startswith(b"BZh")
and sample[3:4] in b"123456789"
and sample[4:10] in (b"1AY&SY", b"\x17rE8P\x90")
)
return (
(artifact["content_kind"] != ContentKind.TEXT and not split_utf8)
# A bounded prefix can split a valid UTF-8 code point. Existing size
# accounting already marks that scan partial; it is not proof that the
# complete source uses an unsupported encoding.
or (not artifact["decodable"] and not split_utf8)
or sample.startswith((b"\xff\xfe", b"\xfe\xff", b"\x00\x00\xfe\xff"))
or sample.startswith((b"\x1f\x8b", b"\xfd7zXZ\x00", b"7z\xbc\xaf\x27\x1c", b"Rar!\x1a\x07"))
or is_tar
or is_bzip2
or (bool(sample) and sample.count(b"\x00") > len(sample) // 4)
)


def build_context(state: SkillspectorState) -> dict[str, object]:
"""Build flat ScanContext fields from state skill_path (local directory).

Expand Down Expand Up @@ -3079,6 +3123,44 @@ def mark_excluded_nested_metadata(
inventory_by_path = {item["path"]: item for item in artifact_inventory}

recognized_containers = frozenset(nested.outer_metadata)
primary_content_events: list[InspectionLedgerEvent] = []
selected_primary = state.get("primary_file_path")
for artifact in artifact_inventory:
path = artifact["path"]
# A skill entry point retains its role below directory and virtual ZIP
# boundaries (e.g. bundle.dat!/pkg/SKILL.md). Renaming a supported ZIP
# must not turn its required instructions into a passive binary asset.
required = path == selected_primary or path.rsplit("/", 1)[-1] in {
"SKILL.md",
"skill.md",
}
if not required or path in recognized_containers:
continue
data = raw_file_cache.get(path)
if data is None or not _unsupported_primary_bytes(artifact, data):
continue
# Explicit input and primary instructions cannot be passive exclusions.
# Keep canonical bytes for byte-based analysis and source attribution,
# while making the missing interpretation fatal to a SAFE verdict.
artifact["content_kind"] = ContentKind.OPAQUE
artifact["disposition"] = ArtifactDisposition.FAILED
artifact["reason"] = LedgerReason.UNSUPPORTED_PRIMARY_CONTENT.value
llm_file_cache.pop(path, None)
primary_content_events.append(
ledger_event(
outcome=LedgerOutcome.FAILED,
record_type=LedgerRecordType.SYSTEM,
phase="cache",
path=path,
reason=LedgerReason.UNSUPPORTED_PRIMARY_CONTENT,
)
)
if path == primary_path:
reference_resolution["complete"] = False
reference_resolution["limitations"] = [
*cast(list[str], reference_resolution.get("limitations", [])),
LedgerReason.UNSUPPORTED_PRIMARY_CONTENT.value,
]
components = sorted(
dict.fromkeys(
[
Expand Down Expand Up @@ -3298,6 +3380,7 @@ def _mark_runtime_partial(affected_paths: list[str], first_limited_path: str) ->
*reference_events,
*cache_events,
*nested.ledger_events,
*primary_content_events,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve primary-content failures when the ledger is truncated

Could we preserve the fatal primary-content outcome independently of the capped detail list? primary_content_events is appended after exclusion_audit_events, so _bounded_ledger_output() can discard it once the 10,000-record limit is reached. finalize_ledger() then derives execution_successful from the surviving exceptions, even though the primary artifact still has disposition="failed".

Reproduced on 14fa2278632a7f3a2e46d2771e6d78b403a3c846 with default limits and scan --no-llm --format json: a UTF-16 SKILL.md containing # Instructions\nSummarize text.\n produces status="failed", execution_successful=false, and exit 2. Adding 5,000 files named node_modules/example/0000.py through 4999.py, each containing pass\n, changes the same scan to status="partial", execution_successful=true, and exit 1; unsupported_primary_content disappears from ledger_exceptions.

The result remains incomplete, so this is not an installation-safety bypass, but it loses the primary failure reason and violates the documented fatal-error/exit-code contract. Please retain a fatal summary across ledger truncation and add a regression combining unsupported primary content with ledger overflow.

*excluded_nested_events,
*manifest_events,
*structured_events,
Expand Down
Loading
Loading