Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
024dc40
fix(static): preserve enclosing quotes in marker scope analysis
yashrajp22 Oct 29, 2026
08b7c1f
fix(static): retain proven shell quote closing positions
yashrajp22 Oct 29, 2026
4d476b0
fix(static): keep shell ownership within proven source regions
yashrajp22 Oct 29, 2026
3765c21
fix(static): bound heredocs and nested shell command ownership
yashrajp22 Oct 29, 2026
fcc6aaf
fix(static): inspect Python string bodies independently of delimiters
yashrajp22 Oct 29, 2026
671db4d
fix(static): prove bounded heredoc and f-string literal ownership
yashrajp22 Oct 29, 2026
5d3f58a
fix(static): reset marker context at proven Python string closers
yashrajp22 Oct 29, 2026
9f3d294
fix(static): retain conservative embedded-language coverage and bound…
yashrajp22 Oct 29, 2026
2b7942b
fix(static): require balanced wrapper proof for marker quote context
yashrajp22 Oct 29, 2026
ad4d108
fix(static): bound literal command quote proof work
yashrajp22 Oct 29, 2026
e1eb24f
fix(static): preserve active and concatenated marker limitations
yashrajp22 Oct 29, 2026
818cf90
fix(static): select bounded quote proofs and retain ambiguous action …
yashrajp22 Oct 29, 2026
6e5434f
style: format static ownership regressions
yashrajp22 Oct 29, 2026
6ad5426
Defer Python shell ownership for ordinary short source
yashrajp22 Oct 29, 2026
ccaac5d
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 29, 2026
4fe5f4d
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 29, 2026
b539826
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 29, 2026
ba8f978
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 29, 2026
506a0c9
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 29, 2026
bcf6dd7
Recognize bounded literal printf comparison operands
yashrajp22 Oct 30, 2026
613d532
Skip empty shell clauses in builtin override proof
yashrajp22 Oct 30, 2026
12c5c4b
Keep shell comparison proof bounded across empty clauses
yashrajp22 Oct 30, 2026
9a813fd
Format shell comparison ownership refinements
yashrajp22 Oct 30, 2026
9a1af39
Bound comparison override checks and recognize continued definitions
yashrajp22 Oct 30, 2026
2644618
Retain uncertainty for truncated function declarations
yashrajp22 Oct 30, 2026
5b63885
Remove unproven builtin comparison exemption
yashrajp22 Oct 30, 2026
2c58fe2
style: format shell startup regression case
yashrajp22 Oct 30, 2026
17c71ef
Preserve generated browser text and shell execution context
yashrajp22 Oct 30, 2026
64af977
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 30, 2026
f9e52fb
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 30, 2026
d781e77
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 30, 2026
643d912
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Oct 31, 2026
45ba7be
fix(parser): resolve shadowed undefined through browser bindings
yashrajp22 Nov 2, 2026
6f486d4
Merge main and preserve host-language parser boundaries
yashrajp22 Nov 2, 2026
45eb37e
fix(parser): bound quoted operands without hiding later commands
yashrajp22 Nov 2, 2026
3a006ed
Merge branch 'main' into yashraj/rt-skills-parser-completeness
github-actions[bot] Nov 2, 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
1,085 changes: 1,085 additions & 0 deletions src/skillspector/javascript_tokens.py

Large diffs are not rendered by default.

963 changes: 908 additions & 55 deletions src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py

Large diffs are not rendered by default.

682 changes: 681 additions & 1 deletion src/skillspector/python_tokens.py

Large diffs are not rendered by default.

105 changes: 97 additions & 8 deletions src/skillspector/security_reconstruction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import re
from array import array
from bisect import bisect_right
from collections import Counter
from collections.abc import Callable, Iterator
from dataclasses import dataclass
Expand Down Expand Up @@ -1246,9 +1247,14 @@ def _bounded_sentence_end(
maximum_end: int,
*,
end_is_truncated: bool,
context_start: int | None = None,
) -> tuple[int, bool]:
for boundary_start, _ in _sentence_boundaries(text, start, min(len(text), maximum_end + 1)):
if boundary_start <= maximum_end:
# A directive can be inside a quoted string; retain that quote's state.
scan_start = start if context_start is None else context_start
for boundary_start, _ in _sentence_boundaries(
text, scan_start, min(len(text), maximum_end + 1)
):
if start <= boundary_start <= maximum_end:
return boundary_start, False
exhausted = maximum_end < len(text) or end_is_truncated
return maximum_end, exhausted
Expand Down Expand Up @@ -1286,11 +1292,23 @@ def _is_fence_line(text: str, cursor: int) -> bool:
)


def _sentence_boundaries(text: str, start: int, end: int) -> Iterator[tuple[int, int]]:
def _sentence_boundaries(
text: str,
start: int,
end: int,
*,
quote_spans: tuple[list[int], list[int]] | None = None,
check_runtime: Callable[[], None] | None = None,
) -> Iterator[tuple[int, int]]:
"""Yield prose boundaries while ignoring punctuation inside balanced quotes."""
quote_close: str | None = None
quote_start = start
cursor = start
next_runtime_check = start
while cursor < end:
if check_runtime is not None and cursor >= next_runtime_check:
check_runtime()
next_runtime_check = cursor + 1024
character = text[cursor]
if quote_close is None and character == "`" and _is_fence_line(text, cursor):
# A Markdown code fence line delimits a block; it does not open a
Expand All @@ -1304,6 +1322,9 @@ def _sentence_boundaries(text: str, start: int, end: int) -> Iterator[tuple[int,
cursor += 2
continue
if character == quote_close:
if quote_spans is not None:
quote_spans[0].append(quote_start)
quote_spans[1].append(cursor + 1)
quote_close = None
cursor += 1
continue
Expand All @@ -1314,6 +1335,7 @@ def _sentence_boundaries(text: str, start: int, end: int) -> Iterator[tuple[int,
and text[cursor - 1].isalnum()
and text[cursor + 1].isalnum()
):
quote_start = cursor
quote_close = _QUOTE_OPEN_TO_CLOSE[character]
elif character == "\n":
yield cursor, cursor + 1
Expand Down Expand Up @@ -1832,30 +1854,39 @@ def _classify_directive(
directive: _Directive,
*,
end_is_truncated: bool,
context_start: int | None = None,
) -> _DirectiveClassification:
if directive.exhausted:
return _DirectiveClassification(None, False, True)

baseline = None
if context_start is not None:
baseline = _classify_directive(view, directive, end_is_truncated=end_is_truncated)
if baseline.active or not baseline.limited:
return baseline

clause_start = _previous_sentence_boundary(
view.text,
directive.start,
MAX_MARKER_LOOKAHEAD_CHARS,
)
scope_cap = min(len(view.text), directive.end + MAX_MARKER_SCOPE_CHARS)
scope_end, _ = _bounded_sentence_end(
view.text,
directive.end,
scope_cap,
end_is_truncated=end_is_truncated,
context_start=context_start,
)
lookahead_cap = min(len(view.text), directive.end + MAX_MARKER_LOOKAHEAD_CHARS)
lookahead_end, lookahead_exhausted = _bounded_sentence_end(
view.text,
directive.end,
lookahead_cap,
end_is_truncated=end_is_truncated,
context_start=context_start,
)
first_sentence_end = lookahead_end
clause_start = _previous_sentence_boundary(
view.text,
directive.start,
MAX_MARKER_LOOKAHEAD_CHARS,
)
directive_clause = view.text[clause_start:first_sentence_end]
if first_sentence_end < lookahead_cap and (
_FORWARD_PAYLOAD_REFERENCE_RE.search(directive_clause) is not None
Expand All @@ -1873,6 +1904,23 @@ def _classify_directive(
end_is_truncated=end_is_truncated,
)
lookahead_exhausted = lookahead_exhausted or continuation_exhausted
if (
baseline is not None
and context_start is not None
and any(
pattern.search(view.text, min(clause_start, context_start), lookahead_end) is not None
for pattern in (
_UNAMBIGUOUS_ACTION_RE,
_CONTEXTUAL_ACTION_RE,
_PASTE_ACTION_RE,
)
)
):
# Even an unbound action can acquire its payload through adjacent
# literals. Wrapper proof may repair an inert method-like header, but
# it cannot clear a prior limit for a potentially active instruction.
# Ambiguous or negated action cues also retain that conservative result.
return baseline
payloads = _quoted_payloads(view.text, directive, scope_end)
if not payloads:
payloads = _anaphoric_payloads(view.text, directive, lookahead_end)
Expand Down Expand Up @@ -1992,6 +2040,7 @@ def build_declared_marker_views(
limited = False
projection_blocked = False
candidates: list[_ProjectionCandidate] = []
quote_spans: tuple[list[int], list[int]] | None = None
for directive in _directives(
view.text,
check_runtime,
Expand All @@ -2014,10 +2063,50 @@ def build_declared_marker_views(
if _verb_is_negated(view.text, directive.start, clause_start):
continue

context_start = None
marker_quote = directive.end - len(directive.marker) - 2
if (
not source_end_is_truncated
and owned_source_start in (None, 0)
and view.source_offset(0) == 0
and directive.marker
and directive.unsupported
and not directive.encoded
and not directive.is_tag
and directive.start <= marker_quote
and _QUOTE_OPEN_TO_CLOSE.get(view.text[marker_quote]) == view.text[directive.end - 1]
and view.text[marker_quote + 1 : directive.end - 1] == directive.marker
):
# A bounded lookbehind can start inside an unrelated literal.
# Only a balanced, different-quote wrapper scanned from the view's
# origin can supply context; a marker opened by that wrapper's
# own closer keeps the conservative forward-only reading.
if quote_spans is None:
quote_spans = ([], [])
for _ in _sentence_boundaries(
view.text,
0,
len(view.text),
quote_spans=quote_spans,
check_runtime=check_runtime,
):
pass
starts, ends = quote_spans
index = bisect_right(starts, directive.start) - 1
if (
index >= 0
and starts[index] < directive.start
and directive.end < ends[index]
and directive.end - starts[index] <= MAX_MARKER_LOOKAHEAD_CHARS
and view.text[starts[index]] != view.text[marker_quote]
):
context_start = starts[index]

classification = _classify_directive(
view,
directive,
end_is_truncated=source_end_is_truncated,
context_start=context_start,
)
limited = limited or classification.limited
if classification.active and classification.limited:
Expand Down
49 changes: 49 additions & 0 deletions tests/nodes/analyzers/test_command_wrapper_without_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,55 @@
_PADDING = "\n# ordinary padding\n" * 400


@pytest.mark.parametrize(
"source",
[
"env",
"env -i",
"env --",
"env -u OTHER",
"env -C /tmp",
"env NAME=value",
"command",
"command -p",
"command --",
"builtin",
"builtin --",
],
)
@pytest.mark.parametrize("ending", ["", "\n", "; echo ready\n", " | cat\n", " # explanation\n"])
def test_optional_command_wrappers_without_executable_are_complete(
source: str, ending: str
) -> None:
assert not _exhausted(source + ending, "shell")


@pytest.mark.parametrize("key", ["command", "builtin", "env"])
def test_json_key_naming_optional_command_wrapper_is_complete(key: str) -> None:
source = '# Output\n\n```json\n{"' + key + '": "echo ready"}\n```\n'

assert not _exhausted(source, "markdown")


@pytest.mark.parametrize(
"source",
[
"env -u",
"env -C",
"env -S",
"env --unknown",
'env >log bash -c "$CMD"',
'command >log bash -c "$CMD"',
'builtin >log eval "$CMD"',
'env -- >log bash -c "$CMD"',
'command -- >log bash -c "$CMD"',
'builtin -- >log eval "$CMD"',
],
)
def test_commandless_wrapper_refinement_keeps_unresolved_operands_partial(source: str) -> None:
assert _exhausted(source, "shell")


def _exhausted(content: str, file_type: str, *, complete_context: bool = True) -> bool:
return tm_module.has_bounded_parse_exhaustion(
content, lambda: None, file_type=file_type, complete_context=complete_context
Expand Down
48 changes: 48 additions & 0 deletions tests/nodes/analyzers/test_eval_command_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,61 @@

"""Eval operands retain their joined command semantics and parsing bounds."""

import shlex

import pytest

from skillspector.inspection_ledger import LedgerOutcome, LedgerReason
from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm_module
from skillspector.nodes.analyzers import static_runner


@pytest.mark.parametrize("setting", ["ENV=prod", "export BASH_ENV=setup.sh"])
@pytest.mark.parametrize("wrapper", ["eval", "builtin eval", "command -p eval"])
@pytest.mark.parametrize("body", ["echo ready", "eval 'echo ready'"])
def test_literal_eval_does_not_load_shell_startup_files(
setting: str, wrapper: str, body: str
) -> None:
content = f"{setting}; {wrapper} {shlex.quote(body)}"

assert not tm_module.has_bounded_parse_exhaustion(content, lambda: None, file_type="shell")


@pytest.mark.parametrize(
"content",
[
"BASH_ENV=setup.sh; eval 'bash -c \"echo ready\"'",
"ENV=prod; builtin eval 'sh -c \"echo ready\"'",
"BASH_ENV=setup.sh; eval \"eval 'bash -c true'\"",
"eval 'export BASH_ENV=setup.sh'; bash -c 'echo ready'",
"builtin eval 'ENV=prod'; eval 'sh -c \"echo ready\"'",
"eval \"eval 'export BASH_ENV=setup.sh'\"; bash -c true",
"ENV=prod; eval 'ENV=; bash -c true'",
"BASH_ENV=setup.sh; eval 'echo \"$(bash -c true)\"'",
"BASH_ENV=setup.sh; eval 'echo \"`bash -c true`\"'",
"eval 'export BASH_ENV=setup.sh; echo \"$(bash -c true)\"'",
"eval 'export BASH_ENV=setup.sh; echo \"`bash -c true`\"'",
'ENV=prod; eval "$SCRIPT"',
],
)
def test_eval_preserves_startup_exposure_for_actual_child_shells(content: str) -> None:
assert tm_module.has_bounded_parse_exhaustion(content, lambda: None, file_type="shell")


def test_eval_startup_settings_inside_child_do_not_escape_to_parent() -> None:
content = "bash -c 'ENV=prod; eval \"echo ready\"'; bash -c 'echo ready'"

assert not tm_module.has_bounded_parse_exhaustion(content, lambda: None, file_type="shell")


def test_literal_eval_with_startup_setting_keeps_destructive_finding() -> None:
content = "ENV=prod; eval 'rm -rf /'"

findings = tm_module.analyze(content, "script.sh", "shell")

assert any(finding.rule_id == "TM1" for finding in findings)


@pytest.mark.parametrize(
"content",
[
Expand Down
Loading
Loading