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
70 changes: 70 additions & 0 deletions docs/ANALYSIS_RESOURCE_BOUNDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,75 @@ charged for each projected occurrence, so a compact alias graph cannot amplify t
manifest past these limits. A malformed or incomplete claimed frontmatter leaves the manifest
empty, marks the primary artifact `partial`, and records an allowlisted parse error or limit reason.

## JSON quote ownership

The deterministic instruction parser can distinguish structural JSON quotes from instruction
delimiters only after validating a complete JSON value within **131,072 source characters**.
This is a structural capacity limit, independent of file-size and scan-time allowances.
Candidates through 65,536 characters keep the established bounded JSON decoder path. Larger
candidates use an iterative syntax validator that does not allocate decoded strings, numbers,
arrays, or objects. A failed small-input decode is never retried through the larger-input path.

The inclusive ceiling counts characters, not UTF-8 bytes or decoded JSON string lengths:

- Standalone JSON includes surrounding whitespace. After bounded frontmatter, only the body
following the closing delimiter is counted.
- An explicitly labeled, closed JSON fence counts its body, including newlines and raw list or
blockquote prefixes. Opening and closing fence lines are excluded.
- Unicode characters count once; each source character of an escape such as `\u0061` counts.
The frontmatter boundary search has its own 65,536-character prefix bound.

The iterative validator reads one complete value and trailing JSON whitespace. It rejects
invalid escapes, raw controls inside strings, malformed numbers, unmatched delimiters, missing
separators, trailing commas, and additional values. No quotes acquire ownership before validation
reaches the end. Validation retains a byte per open container in a non-recursive grammar stack;
the source ceiling therefore bounds nesting storage, token count, and the number of quote spans.
There is no separate decoded-value allocation or integer-conversion cost. Deadline/cancellation
checks occur within whitespace, string, number, and container processing, with fewer than 256
validation characters between checks. The normalized fence-validation copy has a separate
524,288-character ceiling to account for tabs expanding into at most four columns. Quote offsets
continue to refer to the original source. The later quote-span collection is also bounded by
the source characters and retains its existing runtime checks.

This validates structural quote ownership; it does not exempt string contents from security
analysis. Real commands and reconstructed instructions still pass through the existing analyzers.
The unchanged 256,000-character analysis windows and their bounded context remain separate limits.
A JSON value that straddles a window or exceeds another parser/runtime allowance may remain
incomplete, even when its total size is below the JSON ownership ceiling. No unvalidated window
fragment is treated as a complete value.

When marker reconstruction is already incomplete and its first unresolved directive lies in an
oversized JSON candidate, the ledger reports `json_quote_ownership_limit`. Its message identifies
the zero-based, end-exclusive source character span `[start, end)`, observed character count,
limit, and next step; the ledger also retains `observed_characters` and `limit_characters`.
The exception's `path` identifies the source artifact. It does not assert that the candidate is
valid JSON or that capacity is the only unresolved instruction issue. Invalid, truncated, and
oversized candidates never acquire quote ownership. Unrelated parser failures retain their
existing reason codes; an unclosed fence cannot establish a complete JSON body boundary.

To resolve a capacity limitation, split the input into smaller **complete** JSON values or
documents, preserve all required content and references, and rescan. Do not truncate the input
or remove a required reference. Raising the timeout cannot raise this structural ceiling.
Findings remain visible, and unresolved coverage remains incomplete: CLI `--fail-on-incomplete`
returns nonzero and the MCP installation gate rejects it, even after successful semantic analysis.
JSON with no unresolved instruction parsing may still complete without needing quote ownership.

This extends the supported candidate size from 65,536 to 131,072 characters with a different,
bounded validation algorithm. Previously incomplete valid examples can now complete when the
remaining analysis also succeeds. Consumers that enumerate ledger reasons must recognize
`json_quote_ownership_limit` for unresolved candidates exceeding the current ceiling, while
continuing to reject incomplete reports regardless of reason. Do not add the old generic
`obfuscated_instruction_text` as a second exception merely to preserve an exact-string predicate.
That generic reason remains available for separate unresolved instruction parsing.

Historical expectations tied to the 65,536-character ceiling must be retained as versioned
compatibility evidence. A proposed successor contract may require complete analysis for an
otherwise supported 65,537-character benign value and move the size-boundary rejection control to
131,073 characters. Such a dataset migration needs explicit acceptance; it does not retroactively
turn an old failed contract into a pass or weaken an existing expected-complete requirement.
Further increases require new resource measurements and review, including dense strings, deep
nesting, malformed input, cancellation, and analysis-window interactions.

## Intra-bundle references

Reference extraction from the primary instructions is independently bounded:
Expand Down Expand Up @@ -115,6 +184,7 @@ malicious evasion. Keep required references and use the reason to choose the fix
| Reason | Next step |
|---|---|
| `static_parse_limit` | Inspect the expression and analyzer. If valid source is misinterpreted, correct or update the scanner and rerun. |
| `json_quote_ownership_limit` | Use the reported source span and 131,072-character bound to split complete JSON values while retaining required content, then rescan. |
| `read_error`, `stat_error`, `file_disappeared`, `missing_file_cache` | Ensure the resolved target remains readable throughout the scan. |
| `size_limit`, `runtime_limit` | Review the reported bounds and input size; distinguish a scanner performance problem from a legitimate resource ceiling. |
| `binary_content`, `opaque_content` | Provide inspectable source or analysis support for the referenced format. |
Expand Down
29 changes: 29 additions & 0 deletions docs/release/json-quote-ownership-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# JSON quote ownership upgrade

Status: unreleased change after the 2.12.0 baseline. The release version will be
assigned during release preparation; this note does not describe shipped 2.12.0 behavior.

JSON quote ownership supports complete candidates through 131,072 raw source
characters. Values above the former 65,536-character ceiling use iterative syntax
validation without decoded-object allocation. Otherwise supported valid examples
can therefore change from incomplete analysis with strict CLI exit 1 to complete
analysis with exit 0. All other analysis must still succeed.

The smaller decoder path, frontmatter boundary, analysis windows, and runtime
gates retain their existing constraints. Structural ownership does not exempt
commands or instructions inside strings from analysis. Unsupported command
parsing or instruction reconstruction remains explicitly incomplete.

Remaining size-limited uncertainty reports `json_quote_ownership_limit` with
source coordinates and the current ceiling. Consumers that enumerate reason
codes must recognize that reason and reject incomplete reports regardless of its
name. Do not add a duplicate generic reason merely to satisfy an old predicate.

Retain historical size-bound expectations and failures as versioned compatibility
evidence. A successor contract can require completion for the previously rejected
65,537-character benign case and move the rejection control to 131,073 characters.
This is an explicit supported-input migration, not a retrospective pass for an old
oracle. Existing expected-complete requirements must not be weakened.

See [JSON quote ownership bounds](../ANALYSIS_RESOURCE_BOUNDS.md#json-quote-ownership)
for counting rules, validation budgets, remaining limitations, and remediation.
41 changes: 40 additions & 1 deletion src/skillspector/inspection_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class LedgerReason(StrEnum):
OUTPUT_LIMIT = "output_limit"
TRANSITIVE_CHILD_SCAN_FAILED = "transitive_child_scan_failed"
STATIC_PARSE_LIMIT = "static_parse_limit"
JSON_QUOTE_OWNERSHIP_LIMIT = "json_quote_ownership_limit"
OBFUSCATED_INSTRUCTION_TEXT = "obfuscated_instruction_text"


Expand Down Expand Up @@ -210,6 +211,12 @@ class LedgerReason(StrEnum):
LedgerReason.STATIC_PARSE_LIMIT: (
"A security-relevant expression exceeded a bounded static parser's span limit."
),
LedgerReason.JSON_QUOTE_OWNERSHIP_LIMIT: (
"JSON quote ownership was not validated because a candidate source span exceeds "
"the character limit. JSON validity remains unverified; other instruction uncertainty "
"may remain. Split the document into smaller complete JSON values without dropping "
"required content, then rescan. Increasing the scan timeout does not raise this limit."
),
LedgerReason.OBFUSCATED_INSTRUCTION_TEXT: (
"Obfuscated instruction text could not be fully evaluated by the deterministic layer."
),
Expand Down Expand Up @@ -253,6 +260,8 @@ class InspectionLedgerEvent(TypedDict):
stage: NotRequired[str]
observed_characters: NotRequired[int]
limit_characters: NotRequired[int]
source_start_offset: NotRequired[int]
source_end_offset: NotRequired[int]
observed_bytes: NotRequired[int]
limit_bytes: NotRequired[int]
observed_findings: NotRequired[int]
Expand Down Expand Up @@ -293,6 +302,10 @@ class InspectionLedgerException(TypedDict):
error_class: NotRequired[str]
analyzers: NotRequired[list[str]]
fatal: NotRequired[bool]
observed_characters: NotRequired[int]
limit_characters: NotRequired[int]
source_start_offset: NotRequired[int]
source_end_offset: NotRequired[int]


class AnalysisCompleteness(TypedDict):
Expand Down Expand Up @@ -615,7 +628,7 @@ def _exception_from_event(
if outcome == LedgerOutcome.FAILED
else LedgerReason.NO_APPLICABLE_FILES
)
return _exception(
exception = _exception(
outcome=outcome,
phase=str(event["phase"]),
reason=_reason(event.get("reason_code"), fallback),
Expand All @@ -626,6 +639,32 @@ def _exception_from_event(
analyzers=[str(event.get("analyzer_id", ""))],
fatal=fatal,
)
if exception["reason_code"] is LedgerReason.JSON_QUOTE_OWNERSHIP_LIMIT:
# Copy only consistent numeric evidence, never an arbitrary message or
# source payload from graph state. Keep distinct spans distinct when
# the public projection groups contributing analyzers below.
start = event.get("source_start_offset")
end = event.get("source_end_offset")
observed = event.get("observed_characters")
limit = event.get("limit_characters")
if (
type(start) is int
and type(end) is int
and type(observed) is int
and type(limit) is int
and 0 <= start < end
and observed == end - start
and observed > limit > 0
):
exception["source_start_offset"] = start
exception["source_end_offset"] = end
exception["observed_characters"] = observed
exception["limit_characters"] = limit
exception["message"] += (
f" Observed source character span [{start}, {end}) "
f"({observed} characters; limit {limit})."
)
return exception


def _merge_exception_projection(
Expand Down
51 changes: 49 additions & 2 deletions src/skillspector/nodes/analyzers/static_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@
get_python_ast,
)
from skillspector.security_reconstruction import (
_MAX_JSON_QUOTE_CONTAINER_CHARS,
MAX_DECLARED_MARKER_RIGHT_CONTEXT_CHARS,
MAX_MARKER_LOOKAHEAD_CHARS,
build_declared_marker_views,
json_quote_capacity_limit,
)
from skillspector.state import AnalyzerNodeResponse, SkillspectorState, transitive_remaining_seconds

Expand Down Expand Up @@ -1389,6 +1391,7 @@ def _scan_declared_marker_views(
raw_starts: tuple[int, ...],
source_context: _WindowSourceContext,
complete_context: bool,
limited_source_offsets: list[int],
) -> tuple[list[Finding], bool, bool, _StaticResourceLimitError | None]:
"""Reconstruct marker payloads with directive-relative context windows."""
findings: list[Finding] = []
Expand Down Expand Up @@ -1442,6 +1445,13 @@ def check_runtime() -> None:
source_end_is_truncated=raw_end < len(content),
)
projection_limited = projection_limited or reconstruction.limited
if (
reconstruction.first_limited_source_offset is not None
and not limited_source_offsets
):
limited_source_offsets.append(
raw_start + reconstruction.first_limited_source_offset
)
for marker_view in reconstruction.views:
if not marker_view.source_offsets:
continue
Expand Down Expand Up @@ -1577,6 +1587,7 @@ def _scan_all_views_detailed(
clock=time.monotonic,
)
marker_projection_limited = False
limited_source_offsets: list[int] = []
modules_for_windows = lexical_modules or ([] if ast_modules else pattern_modules)
bounded_parse_limited = False
marker_owned_starts: tuple[int, ...] = ()
Expand Down Expand Up @@ -1630,6 +1641,7 @@ def _scan_all_views_detailed(
raw_starts=marker_raw_starts,
source_context=source_context,
complete_context=whole_artifact_window,
limited_source_offsets=limited_source_offsets,
)
bounded_parse_limited = bounded_parse_limited or marker_bounded_parse_limited
except _StaticResourceLimitError as exc:
Expand Down Expand Up @@ -1936,6 +1948,27 @@ def _scan_all_views_detailed(
"limit_findings": max_findings,
},
)
if limited_source_offsets and not (python_syntax_error or bounded_parse_limited):
try:
capacity_span = json_quote_capacity_limit(
content,
finding_budget.check_runtime,
containing_offset=limited_source_offsets[0],
)
except _StaticResourceLimitError as exc:
return deduplicated, exc.reason, exc.metrics
if capacity_span is not None:
start, end = capacity_span
return (
deduplicated,
LedgerReason.JSON_QUOTE_OWNERSHIP_LIMIT,
{
"observed_characters": end - start,
"limit_characters": _MAX_JSON_QUOTE_CONTAINER_CHARS,
"source_start_offset": start,
"source_end_offset": end,
},
)
return (
deduplicated,
(
Expand Down Expand Up @@ -2354,10 +2387,18 @@ def run_static_patterns_with_ledger(
reason=partial_reason if partial else None,
emitted_finding_ids=[finding.finding_id for finding in path_findings],
observed_characters=(
len(content) if partial_reason is LedgerReason.SIZE_LIMIT else None
int(resource_metrics["observed_characters"])
if partial_reason is LedgerReason.JSON_QUOTE_OWNERSHIP_LIMIT
else len(content)
if partial_reason is LedgerReason.SIZE_LIMIT
else None
),
limit_characters=(
MAX_FILE_CHARS if partial_reason is LedgerReason.SIZE_LIMIT else None
int(resource_metrics["limit_characters"])
if partial_reason is LedgerReason.JSON_QUOTE_OWNERSHIP_LIMIT
else MAX_FILE_CHARS
if partial_reason is LedgerReason.SIZE_LIMIT
else None
),
observed_findings=(
int(resource_metrics.get("observed_findings", len(path_findings)))
Expand All @@ -2384,6 +2425,12 @@ def run_static_patterns_with_ledger(
else None
),
)
if partial_reason is LedgerReason.JSON_QUOTE_OWNERSHIP_LIMIT:
# Keep the file-level work identity; the diagnostic span is
# not a separately planned inspection range. Finalization
# projects these numeric facts into a safe public message.
event["source_start_offset"] = int(resource_metrics["source_start_offset"])
event["source_end_offset"] = int(resource_metrics["source_end_offset"])
events.append(event)

return {
Expand Down
Loading