Skip to content

fix(analyzer): treat a command wrapper with no command as complete - #779

Merged
rng1995 merged 1 commit into
NVIDIA:mainfrom
elliottwaves-20:fix/694-wrapper-without-command
Oct 14, 2026
Merged

rng1995 merged 1 commit into
NVIDIA:mainfrom
elliottwaves-20:fix/694-wrapper-without-command

Conversation

@elliottwaves-20

@elliottwaves-20 elliottwaves-20 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A Markdown parameter table such as

| Name | Type | Default | Description |
|---|---|---|---|
| timeout | float | 150 | Seconds to wait |

makes has_bounded_parse_exhaustion (static_patterns_tool_misuse) report static_parse_limit, so the file is recorded as partially inspected and every SKILL.md reference to it becomes an AE1. The same happens in host code such as signal.alarm(timeout) or if (timeout) {. Refs #694 (the Markdown table-cell case reported there).

Root cause

_shell_clause_starts opens a new clause after every | and (, so timeout above is read as the wrapper command timeout. Its next token is the control operator |, so _next_shell_invocation_word returns None. In _command_string_from_clause the sudo/nice/xargs and timeout branches answer None with (True, None), which means "unresolved command string" and leads to exhaustion. The env/command/nohup path answers the same situation with (False, None) (no command). That is why timeout, sudo, nice and xargs reproduce and env, nohup and command do not, as noted in #694. timeout 5 | was already complete, because there the empty command is seen by the generic path.

Fix

The wrappers that report no command string are sudo, nice, xargs and timeout. They do so when their next token is ;, |, ) or &, as long as the & is not followed by >. None of these wrappers can run another command at that point: sudo and timeout fail with usage, nice prints the niceness, and xargs defaults to echo. A new helper _ends_wrapped_clause decides this.

Unchanged (stay partial):

  • A redirection before the wrapped command (sudo >log $CMD -rf /, sudo <in …, sudo &>log …).
  • (, and the end of the view, where a fragment may continue.
  • Any wrapper option or -- handling.
  • sh -c/eval command strings.
  • Every rule finding from analyze(). For example, `sudo rm -rf /` in a table cell still produces TM1.

Tests

tests/nodes/analyzers/test_command_wrapper_without_command.py has 29 tests. Rebased onto main b07f181, 16 of them fail on main, and all 12 fail-closed controls pass on both. The Python signal.alarm(timeout) case already passes on main since #778 (it failed on the original base); it stays as a pin.

  • Benign ⇒ complete:
    • Markdown cells with timeout, sudo, nice and xargs, with and without a long tail.
    • An API parameter table.
    • Shell sudo | cat, sudo |& cat, timeout; echo, timeout && echo, xargs || true, nice & wait, (nice), a nice ;; case item and sudo -u | cat.
    • Python signal.alarm(timeout) and JavaScript if (timeout).
  • Controls ⇒ partial: redirections (>, <, &>), timeout ($CMD) -rf /, x | sudo sh -c "$CMD", sudo |& sh -c "$CMD", sudo && eval "$CMD", sudo -s; eval "$CMD", case $a in sudo) $CMD -rf / ;; esac, and a wrapper at the end of the view (complete and fragment context).
  • Findings kept: a table cell with `sudo rm -rf /` keeps TM1, and `sudo $CMD -rf /` stays partial.
  • Scan level: run_static_patterns_with_ledger gives COMPLETED for the table and PARTIAL/STATIC_PARSE_LIMIT for the redirected wrapper.
  • Full unit suite: the set of failures is identical with and without this change, which I ran together with fix(analyzer): end an expansion word at its enclosing double quote #780 (double-quoted expansion case) (10108 passed). The remaining failures and errors on this Windows machine also occur on unmodified main and are unrelated: release/CLI path tests and three parametrized collection errors.
  • ruff check and ruff format --check are clean.
  • Real files: I ran a static check over 4000 files from about 200 public skills, including their vendored venvs. 29 files go from partial to complete, for example urllib3/connectionpool.py, grpc/_channel.py and two Tavily reference pages. No file goes from complete to partial, and the TM1 count is unchanged (60).
  • Differential fuzzing against main: I generated about 3000 random shell fragments built from wrappers, operators, quotes, substitutions and rm/-rf / pieces. Every input that went from partial to complete was checked. The ones that bash -n accepts are heredoc text, comments or wrappers without a command, such as timeout |echo and $(sudo ). No input went from complete to partial.
  • The change merges cleanly with fix(static): stop code comparisons from reading as tag marker directives #777 and fix(analyzer): keep Python and Perl syntax from exhausting the shell parser #778. Applied on top of fix(analyzer): keep Python and Perl syntax from exhausting the shell parser #778, its 52 tests and this PR's tests pass together.

Residual

sudo -s/sudo -i before a control operator now counts as "no command string", the same as sh at the end of a pipeline already does. Stdin-fed shells are outside what this check models, and the docstring says so.

#778 also handles the timeout wrapper names inside valid Python through Python token ownership. This PR covers the same root cause for every file type, including Markdown, JavaScript and Python fragments, and does not conflict with #778.

🤖 Generated with Claude Code

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

@elliottwaves-20 thank you for tracing the timeout/sudo/nice/xargs vs env/nohup/command asymmetry from #694 back to the one (True, None) branch, and for fixing it with a small, clearly documented helper.

Value and readiness

This fixes a real completeness false positive. On current main (7ee4371), a Markdown table row such as | timeout | float | 150 | ... | or | sudo | ... |, or JS if (timeout) {, still makes has_bounded_parse_exhaustion return True. At scan level, run_static_patterns_with_ledger then records the SKILL.md as partial/static_parse_limit. That false partial is what turns benign references into AE1.

The change only reclassifies a wrapper that is followed directly by ;, |, ) or & (but not &>): it goes from "unresolved command string" to "no command". That matches shell semantics. None of these wrappers can run a command at that point, and anything after the operator starts its own clause or candidate, which is still checked. Redirections, ( and the end of the view stay partial. The change can only move results toward "complete", and every path to that I traced is a genuinely empty wrapper. Ready for final maintainer review.

Material findings

None blocking.

  • Optional: the python-call case (signal.alarm(timeout)) in tests/nodes/analyzers/test_command_wrapper_without_command.py already passes on current main. #778 (025c65e) now skips clauses that start in Python code. The case is harmless, but it no longer guards this change. The Markdown, JS and shell cases still do. The description's "17 fail on main" was measured on the older base.

PIC tradeoffs

  • sudo -s/sudo -i, or any of these wrappers, directly before ;, | or & now counts as "no command string", so echo "$CMD" | sudo -s ; becomes complete. That matches main's current handling of echo "$CMD" | sh ; and | sudo bash ;, which I confirmed are both complete on main. The new docstring says stdin-fed shells are not modelled. This is consistent, but it remains a known gap for stdin-fed shells.

Verification and gaps

  • Read the diff and, after merging it onto main, _command_string_from_clause, _next_shell_invocation_word (returns None only at end of input or on );|&()<>), _shell_clause_starts, _shell_command_strings and has_bounded_parse_exhaustion. git merge-tree against main is clean, and the merged file differs from main only in the two branches and the helper.
  • Traced the controls. sudo >log $CMD -rf /, sudo <in ..., sudo &>log ... and timeout ($CMD) -rf / still return (True, None). x | sudo sh -c "$CMD", sudo && eval "$CMD" and case $a in sudo) $CMD -rf / ;; stay partial through their own clause or candidate.
  • Ran only trusted main code, never PR code. It confirmed that the table, JS and shell cases are partial on main, that the Python case is already complete, that the TM1 finding for a `sudo rm -rf /` table cell exists, and that the ledger enums match the tests' assertions.
  • Gap: CI is green, but it last ran on 2026-10-06. Since then main has changed this file in five commits (#577, #578, #579, #778, #784). The merge is textually clean; please let the refreshed CI pass before merging. I did not reproduce the corpus or fuzzing claims.

A clause such as `| timeout |` in a Markdown parameter table, or
`signal.alarm(timeout)` in host code, makes `_command_string_from_clause`
see the wrapper `timeout` (also `sudo`, `nice`, `xargs`) followed directly
by a control operator. Those branches returned "unresolved command
string", so the file was recorded as `static_parse_limit`. The `env`,
`command` and `nohup` branches already treat the same situation as "no
command".

When the wrapper's next token is `;`, `|`, `)` or `&` (but not `&>`), no
other command can run, so report no command string. A redirection, a `(`,
`&>` and the end of the view stay unresolved.

Refs NVIDIA#694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: elliottwaves-20 <pail1217@web.de>
@elliottwaves-20
elliottwaves-20 force-pushed the fix/694-wrapper-without-command branch from 3cd031b to 623284f Compare October 13, 2026 10:22
@elliottwaves-20

Copy link
Copy Markdown
Contributor Author

@rng1995 Rebased onto main (b07f181) so CI runs on the current base; the change itself is unchanged. On this base 16 of the 29 tests fail on main. As you noted, the Python signal.alarm(timeout) case already passes since #778. I kept it as a pin and corrected the count in the PR body. Full suite on Windows: the set of failing tests is identical to unchanged main.

🤖 Generated with Claude Code

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

@elliottwaves-20 thank you for rebasing onto current main so the change could get a fresh CI run.

Value and readiness

This is a re-review of the rebased head 623284fa. It is still approved and ready to merge. The rebase settles the one gap I raised in my earlier review: CI had last run before five main commits touched static_patterns_tool_misuse.py.

Material findings

Prior findings

  • Resolved (gap): stale CI against main's changes to _command_string_from_clause. The branch now sits directly on main (b07f181d), and all 6 checks passed on this head (test-unit finished 12:15 UTC).
  • Still open (optional, unchanged): the python-call test case already passes on main since #778. It is harmless but no longer guards this change.

New: none.

PIC tradeoffs

Unchanged from my earlier review. A wrapper directly before ;, | or & (for example | sudo -s ;) is now treated as "no command string". That matches main's existing handling of stdin-fed shells, which remain unmodelled.

Verification and gaps

  • Diff: the added and removed lines of the PR's own diff are byte-identical between the approved 3cd031b0 and 623284fa. The rebase changed only the base.
  • Context on main: no files under src/skillspector/nodes/analyzers/ changed between the main commit my earlier review traced against (789f5175) and the current base (b07f181d). The behaviour I traced earlier therefore still holds.
  • CI and state: 6/6 checks are green on this head, mergeable is CLEAN, there are no unresolved threads, and the PR has no closing references.

@rng1995
rng1995 merged commit 5d8f538 into NVIDIA:main Oct 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants