Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8a7690d
fix: keep unsupported primary input incomplete
mohgupta-ship-it Sep 16, 2026
c6aa326
fix: retain incomplete coverage for multiline prompt spacing
mohgupta-ship-it Sep 16, 2026
b7cf525
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
08f9fd4
fix: close primary identity gaps and bound multiline matching
mohgupta-ship-it Sep 16, 2026
2409a74
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
de307de
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
55fe14e
merge: preserve completeness evidence across main synchronization
mohgupta-ship-it Sep 16, 2026
a311b69
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
131ffa8
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
264ba73
test: retain nested exclusion evidence with primary failure
mohgupta-ship-it Sep 16, 2026
14fa227
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
0174f71
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
bd10113
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 16, 2026
183fd55
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 17, 2026
5d510aa
Merge branch 'main' into codex/fix-scan-completeness-20260916
zozozeezee Sep 22, 2026
d9cba63
fix(scan): preserve fatal input outcomes across bounded inspection
zozozeezee Sep 22, 2026
e0418c9
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 22, 2026
ea9e958
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 22, 2026
1c1755b
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 2026
6579961
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 2026
df29b4d
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 2026
24bc760
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 2026
dfcde7b
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 2026
8d7d4d6
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 2026
b1b4ffb
Merge branch 'main' into codex/fix-scan-completeness-20260916
github-actions[bot] Sep 23, 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
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 @@ -331,6 +331,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 @@ -1997,6 +2005,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)
)


@lru_cache(maxsize=_TEXT_PREDICATE_CACHE_SIZE)
def _requires_normalized_security_view(text: str) -> bool:
"""Return whether normalization can produce a distinct security view."""
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 @@ -769,6 +769,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 @@ -788,6 +789,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

git_target = self._github_tree_target(input_path)
if git_target is not None:
Expand Down Expand Up @@ -1349,6 +1351,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 @@ -1371,6 +1374,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 @@ -1599,4 +1603,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
38 changes: 32 additions & 6 deletions src/skillspector/inspection_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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_MISSING = "reference_missing"
Expand Down Expand Up @@ -173,6 +174,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 Expand Up @@ -887,6 +892,33 @@ def accounting_error(path: object = None) -> None:
]
exceptional_rows.extend(unaccounted_exceptions)
exceptional_rows.extend(accounting_exceptions)
raw_inventory = state.get("artifact_inventory", [])
inventory = (
[item for item in raw_inventory if isinstance(item, dict)]
if isinstance(raw_inventory, list)
else []
)
fatal_reasons = {
(row["path"], row["reason_code"]) for row in exceptional_rows if row.get("fatal")
}
# The bounded detail ledger may omit a cache failure. Canonical inventory
# still owns the artifact's disposition: truncation cannot restore success.
for artifact in inventory:
if artifact.get("disposition") != "failed":
continue
path = _safe_path(artifact.get("path"), components)
reason = _reason(artifact.get("reason"), LedgerReason.READ_ERROR)
if (path, reason) not in fatal_reasons:
exceptional_rows.append(
_exception(
outcome=LedgerOutcome.FAILED,
phase="cache",
reason=reason,
path=path,
fatal=True,
)
)
fatal_reasons.add((path, reason))
ledger_exceptions = _merge_exception_projection(exceptional_rows)
scope_exclusions = _merge_exception_projection(scope_rows)

Expand All @@ -912,12 +944,6 @@ def accounting_error(path: object = None) -> None:
LedgerOutcome.FAILED if component in cache_failures else LedgerOutcome.COMPLETED
)

raw_inventory = state.get("artifact_inventory", [])
inventory = (
[item for item in raw_inventory if isinstance(item, dict)]
if isinstance(raw_inventory, list)
else []
)
disposition_by_path = {
str(item.get("path", "")): str(item.get("disposition", "")) for item in inventory
}
Expand Down
6 changes: 6 additions & 0 deletions src/skillspector/nested_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ class NestedInspectionResult:
artifact_inventory: list[ArtifactRecord] = field(default_factory=list)
metadata: list[dict[str, object]] = field(default_factory=list)
outer_metadata: dict[str, dict[str, object]] = field(default_factory=dict)
# Byte-recognized ZIPs at every depth, including ones stopped by a limit.
# Expected extensions and format mismatches are not content recognition.
recognized_zip_paths: set[str] = field(default_factory=set)
ledger_events: list[InspectionLedgerEvent] = field(default_factory=list)
uncompressed_bytes: int = 0
# Exceptions can target a top-level container before a virtual artifact row
Expand Down Expand Up @@ -1024,6 +1027,7 @@ def _inspect_zip_bytes(

if not nested_zip:
continue
result.recognized_zip_paths.add(virtual_path)
if depth >= budget.max_depth:
_exception(
result,
Expand Down Expand Up @@ -1131,6 +1135,7 @@ def inspect_nested_artifacts(
except (OSError, _FileOpenError, _UnsafeFileError):
continue
if _is_zip_signature(signature):
result.recognized_zip_paths.add(path)
_record_outer_metadata(
result,
path=path,
Expand Down Expand Up @@ -1182,6 +1187,7 @@ def inspect_nested_artifacts(
continue
# Record a conservative local-only identity before parsing the central
# directory. The bounded inspector refines this after its early checks.
result.recognized_zip_paths.add(path)
_record_outer_metadata(
result,
path=path,
Expand Down
109 changes: 108 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,98 @@ 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:
# Keep this short, interruptible search on the current thread.
# Releasing the GIL lets another analyzer consume its wall-clock
# allowance and turn ordinary prose into a false timeout.
for match in pattern.finditer(matching_text, timeout=timeout, concurrent=False):
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
Loading
Loading