Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
0d2bf59
fix(security): enforce deadlines for projected prompt matching
yashrajp22 Oct 5, 2026
a0d1f2b
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
2fa778a
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
c8ed599
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
992e1ec
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
734cdea
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
bfb91b4
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
c9a7906
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
070b5f8
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
060b015
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
9c7edf0
fix(analyzers): isolate projected matching deadlines from caller CPU
yashrajp22 Oct 5, 2026
85218b5
test(analyzers): model per-search CPU and exhausted iterators
yashrajp22 Oct 5, 2026
680d4c5
style(tests): format projected matcher regression cases
yashrajp22 Oct 5, 2026
2acad0b
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
2a5d1b7
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
0314959
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
2f79822
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
2df342f
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
080a3b6
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
891ecde
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 5, 2026
6a002fe
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
05426b7
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
f5dfafe
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
d9486cf
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
0439631
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
ab335d0
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
dbb2b42
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
d6fc2bb
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
0cd0676
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
b311577
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
45af84d
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
88d3599
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 6, 2026
c9ce9ee
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 7, 2026
66064f7
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 7, 2026
d36c143
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 7, 2026
993ffdc
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 8, 2026
16c50cd
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 8, 2026
ea499eb
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 8, 2026
5a36289
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 9, 2026
d4b4deb
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 9, 2026
32cad40
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 9, 2026
b31a638
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 9, 2026
373691a
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 9, 2026
5a5a51d
Merge branch 'main' into yashraj/fix-projected-prompt-regex-timeouts
github-actions[bot] Oct 9, 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
118 changes: 72 additions & 46 deletions src/skillspector/nodes/analyzers/artifact_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@
_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.
# Reconstructed projections can give 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)
Expand Down Expand Up @@ -674,9 +674,9 @@ def _projected_prompt_injection_line(
else (view.text,)
)
for projected_text in projected_texts:
for pattern in _PROJECTED_PROMPT_PATTERNS:
budget.check_runtime()
match = pattern.search(projected_text)
matching_text = _multiline_prompt_matching_text(projected_text, budget)
for pattern in _MULTILINE_PROMPT_PATTERNS:
match = next(_timed_prompt_matches(pattern, matching_text, budget), None)
if match is None:
continue
reconstructed_gaps = view.reconstructed_source_spans(match.start(), match.end())
Expand All @@ -697,9 +697,9 @@ def _projected_prompt_injection_line(
)
candidate_offsets = tuple(point[0] for point in join_points)
for projected_text in irregular_texts:
for pattern in _PROJECTED_PROMPT_PATTERNS:
budget.check_runtime()
for match in pattern.finditer(projected_text):
matching_text = _multiline_prompt_matching_text(projected_text, budget)
for pattern in _MULTILINE_PROMPT_PATTERNS:
for match in _timed_prompt_matches(pattern, matching_text, budget):
budget.check_runtime()
point_index = bisect_right(candidate_offsets, match.start())
if (
Expand Down Expand Up @@ -751,6 +751,51 @@ def _multiline_prompt_matching_text(text: str, budget: _ArtifactIntegrityBudget)
return "".join(parts)


def _timed_prompt_matches(
pattern: regex.Pattern[str],
text: str,
budget: _ArtifactIntegrityBudget,
) -> Iterator[regex.Match[str]]:
"""Apply the same interruptible matching budget to every prompt projection."""
matching_seconds = 0.0
matching_limit = _MULTILINE_PROMPT_PATTERN_SECONDS
start = 0
skip_empty = False
while True:
budget.check_runtime()
remaining = transitive_remaining_seconds(budget.state)
if remaining is not None:
matching_limit = min(matching_limit, matching_seconds + max(0.0, remaining))
timeout = matching_limit - matching_seconds
if timeout <= 0:
raise _ArtifactIntegrityResourceLimitError(
LedgerReason.RUNTIME_LIMIT,
{"observed_seconds": matching_seconds, "limit_seconds": matching_limit},
)
started_at = time.thread_time()
expired = False
try:
# Restart across yields so consumer work never uses the match budget.
matches = pattern.finditer(text, pos=start, timeout=timeout, concurrent=False)
if skip_empty:
next(matches, None)
match = next(matches, None)
except TimeoutError:
expired = True
finally:
matching_seconds += max(0.0, time.thread_time() - started_at)
budget.check_runtime()
if expired:
# regex counts process CPU, including native work in other threads.
# Retry only the same search; the thread and workflow budgets still bound it.
continue
if match is None:
return
start = match.end()
skip_empty = match.start() == start
yield match


def _multiline_prompt_injection_line(
content: str,
budget: _ArtifactIntegrityBudget,
Expand All @@ -762,46 +807,27 @@ def _multiline_prompt_injection_line(
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):
for match in _timed_prompt_matches(pattern, matching_text, budget):
# 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()
# 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
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
return get_line_number(content, first_offset) if first_offset is not None else None


Expand Down
107 changes: 105 additions & 2 deletions tests/nodes/analyzers/test_multiline_prompt_spacing.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,11 @@ def test_multiline_regex_timeout_preserves_partial_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
timeouts = []
clock = iter((0.0, 0.02))
monkeypatch.setattr(artifact_integrity.time, "thread_time", lambda: next(clock))

class ExpiredPattern:
def finditer(self, _text, *, timeout, concurrent):
def finditer(self, _text, *, pos, timeout, concurrent):
timeouts.append(timeout)
raise TimeoutError("regex timed out")

Expand Down Expand Up @@ -379,7 +381,9 @@ def compete_for_gil() -> None:
class ContendedPattern:
def finditer(self, text, **kwargs):
matches = original.finditer(text, **kwargs)
first = next(matches)
first = next(matches, None)
if first is None:
return
start_work.set()
yield first
yield from matches
Expand Down Expand Up @@ -426,3 +430,102 @@ async def test_parallel_scan_of_contractions_remains_complete(tmp_path: Path) ->
assert result["analysis_completeness"]["is_complete"] is True
mcp = await run_scan(str(tmp_path), use_llm=False)
assert mcp["safe_to_install"] is True


@pytest.mark.parametrize("irregular", [False, True])
def test_projected_prompt_regex_timeout_records_incomplete_coverage(
monkeypatch: pytest.MonkeyPatch, irregular: bool
) -> None:
calls = []
clock = iter((0.0, 0.02, 0.02, 0.04))
monkeypatch.setattr(artifact_integrity.time, "thread_time", lambda: next(clock))
monkeypatch.setattr(
artifact_integrity,
"_multiline_prompt_injection_line",
lambda *_args: pytest.fail("ordinary projection reached the multiline fallback"),
)

class ExpiredPattern:
def finditer(self, text, *, pos, timeout, concurrent):
calls.append((timeout, concurrent))
# Exercise the irregular projection after the normal search.
if irregular and len(calls) == 1:
return iter(())
raise TimeoutError("regex timed out")

monkeypatch.setattr(artifact_integrity, "_MULTILINE_PROMPT_PATTERNS", (ExpiredPattern(),))
monkeypatch.setattr(artifact_integrity, "transitive_remaining_seconds", lambda _state: 0.01)
if irregular:
monkeypatch.setattr(
artifact_integrity,
"_irregular_spacing_prompt_projection",
lambda _view: ("without telling the user", ((1, 0),)),
)
content = "w i t h o u t t e l l i n g u s e r"
result = node({"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}})
assert len(calls) == (2 if irregular else 1)
assert all(call == (0.01, False) for call in calls)
assert all(event["outcome"] == "partial" for event in result["inspection_ledger"])
assert all(
event["reason_code"] == LedgerReason.RUNTIME_LIMIT for event in result["inspection_ledger"]
)


def test_repeated_projected_prompt_prefix_has_engine_deadline(
Comment thread
yashrajp22 marked this conversation as resolved.
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(artifact_integrity, "_MULTILINE_PROMPT_PATTERN_SECONDS", 0.000001)
monkeypatch.setattr(
artifact_integrity,
"_multiline_prompt_injection_line",
lambda *_args: pytest.fail("ordinary projection reached the multiline fallback"),
)
content = "w i t h o u t t e l l i n g u s e r " * 1000
with pytest.raises(artifact_integrity._ArtifactIntegrityResourceLimitError) as caught:
artifact_integrity._projected_prompt_injection_line(
content, artifact_integrity._ArtifactIntegrityBudget({})
)
assert caught.value.reason == LedgerReason.RUNTIME_LIMIT


def test_timed_prompt_matches_excludes_consumer_cpu(monkeypatch: pytest.MonkeyPatch) -> None:
import time

import regex

monkeypatch.setattr(artifact_integrity, "_MULTILINE_PROMPT_PATTERN_SECONDS", 0.01)
matches = artifact_integrity._timed_prompt_matches(
regex.compile(r".*?"), "ab", artifact_integrity._ArtifactIntegrityBudget({})
)
first = next(matches)
until = time.thread_time() + 0.03
while time.thread_time() < until:
pass
assert [first.span(), *(match.span() for match in matches)] == [
(0, 0),
(0, 1),
(1, 1),
(1, 2),
(2, 2),
]


def test_timed_prompt_matches_retries_other_thread_cpu(monkeypatch: pytest.MonkeyPatch) -> None:
import regex

calls = 0
original = regex.compile("x")

class ContendedPattern:
def finditer(self, text, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise TimeoutError("another thread used process CPU")
return original.finditer(text, **kwargs)

matches = artifact_integrity._timed_prompt_matches(
ContendedPattern(), "x", artifact_integrity._ArtifactIntegrityBudget({})
)
assert [match.span() for match in matches] == [(0, 1)]
assert calls == 3