-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix: preserve incomplete scans for unsupported input and multiline prompts #563
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8a7690d
c6aa326
b7cf525
08f9fd4
2409a74
de307de
55fe14e
a311b69
131ffa8
264ba73
14fa227
0174f71
bd10113
183fd55
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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). | ||
|
|
||
|
|
@@ -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( | ||
| [ | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? Reproduced on 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, | ||
|
|
||
There was a problem hiding this comment.
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, andregex.finditer()releases the GIL by default (documented behavior). On HEAD183fd554, a 6.8 KB prose document that is complete on the base becomes partial in both source and installed-wheel scans, withruntime_limiton 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 toconcurrent=Falsein 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:
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.