Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/beta-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ body:
attributes:
label: Hermes Plugin Guard version
description: Run `hpg --version` and paste the result.
placeholder: hermes-plugin-guard 0.1.3
placeholder: hermes-plugin-guard 0.1.4
validations:
required: true

Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CodeQL

on:
push:
branches:
- main
pull_request:
branches:
- main
schedule:
- cron: "23 4 * * 1"

permissions:
contents: read
security-events: write

concurrency:
group: codeql-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
analyze:
name: Python
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: python
build-mode: none
config: |
paths:
- src/hermes_plugin_guard

- name: Analyze
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.1.4] - 2026-08-01

### Added

- Run pinned CodeQL analysis for the production Python package on pull requests, pushes to
`main`, and a weekly schedule.

### Fixed

- Apply the existing ignored-directory policy to symlink validation so virtual-environment and
other non-production symlinks do not produce `HPG002` findings, while out-of-root symlinks in
scanned plugin paths remain blocked and reported.
- Recognize only statically verified subclasses of `yaml.SafeLoader` and `yaml.CSafeLoader` as safe
`yaml.load()` loaders, removing a false positive without trusting lookalike, decorated, unsafe,
or rebound loader classes.

## [0.1.3] - 2026-07-28

### Added
Expand Down Expand Up @@ -79,7 +95,8 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Configurable failure thresholds and per-rule command-line exclusions.
- Composite GitHub Action and pinned continuous-integration workflow.

[Unreleased]: https://github.com/mauricemohr88-debug/hermes-plugin-guard/compare/v0.1.3...HEAD
[Unreleased]: https://github.com/mauricemohr88-debug/hermes-plugin-guard/compare/v0.1.4...HEAD
[0.1.4]: https://github.com/mauricemohr88-debug/hermes-plugin-guard/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/mauricemohr88-debug/hermes-plugin-guard/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/mauricemohr88-debug/hermes-plugin-guard/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/mauricemohr88-debug/hermes-plugin-guard/compare/v0.1.0...v0.1.1
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ basic repository hygiene. Target plugin code is read as data: it is never import
This is an unofficial community project. It is not affiliated with, endorsed by, or maintained
by Nous Research.

![20-second terminal demo of installing and running Hermes Plugin Guard](https://raw.githubusercontent.com/mauricemohr88-debug/hermes-plugin-guard/v0.1.3/docs/demo.gif)
![20-second terminal demo of installing and running Hermes Plugin Guard](https://raw.githubusercontent.com/mauricemohr88-debug/hermes-plugin-guard/v0.1.4/docs/demo.gif)

## Five-minute beta test

Expand Down Expand Up @@ -45,11 +45,11 @@ target plugin code, makes no network requests, includes no telemetry, and upload
code nor results. Do not paste private code, credentials, or unsanitized paths into a public issue.

Already installed? Use `pipx upgrade hermes-plugin-guard`. For a reproducible installation
directly from the tagged source, install the v0.1.3 GitHub release:
directly from the tagged source, install the v0.1.4 GitHub release:

```bash
pipx install \
"git+https://github.com/mauricemohr88-debug/hermes-plugin-guard.git@v0.1.3"
"git+https://github.com/mauricemohr88-debug/hermes-plugin-guard.git@v0.1.4"
```

## Why this exists
Expand Down Expand Up @@ -86,7 +86,7 @@ Alternatively, install reproducibly from the tagged GitHub source:

```bash
pipx install \
"git+https://github.com/mauricemohr88-debug/hermes-plugin-guard.git@v0.1.3"
"git+https://github.com/mauricemohr88-debug/hermes-plugin-guard.git@v0.1.4"
```

Or install from a local checkout:
Expand Down Expand Up @@ -182,7 +182,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: mauricemohr88-debug/hermes-plugin-guard@v0.1.3
- uses: mauricemohr88-debug/hermes-plugin-guard@v0.1.4
with:
path: path/to/plugin
fail-on: high
Expand Down
Binary file modified docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,14 @@ def main() -> None:
pause(450, cursor=True)
type_line("$ pipx install hermes-plugin-guard", CYAN, step=1, delay=80)
pause(850, cursor=True)
reveal(" installed package hermes-plugin-guard 0.1.3", MUTED, duration=650)
reveal(" installed package hermes-plugin-guard 0.1.4", MUTED, duration=650)
reveal(" apps now available: hpg, hermes-plugin-guard", GREEN, duration=850)
reveal("", duration=350)

# The report below is the exact text format produced by the safe fixture.
type_line("$ hpg scan ~/.hermes/plugins/my-plugin", CYAN, step=1, delay=70)
pause(1_150, cursor=True)
reveal("hermes-plugin-guard 0.1.3", MUTED, duration=500)
reveal("hermes-plugin-guard 0.1.4", MUTED, duration=500)
reveal("Scanned 1 plugin(s), 4 file(s) — 0 finding(s)", TEXT, duration=600)
reveal("Summary: no findings", MUTED, duration=600)
reveal("Result: PASS (no finding at or above high)", GREEN, duration=950)
Expand Down
2 changes: 1 addition & 1 deletion docs/social-card.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "hermes-plugin-guard"
version = "0.1.3"
version = "0.1.4"
description = "Static, no-execution security checks for Hermes Agent plugins"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/hermes_plugin_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
from .scanner import scan

__all__ = ["Finding", "ScanResult", "Severity", "scan"]
__version__ = "0.1.3"
__version__ = "0.1.4"
27 changes: 13 additions & 14 deletions src/hermes_plugin_guard/python_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@
"metadata.google.internal",
}
CLEARTEXT_SCHEMES = {"ftp", "grpc", "http", "ws"}
SAFE_YAML_LOADERS = {"yaml.CSafeLoader", "yaml.SafeLoader"}
SAFE_YAML_SUBCLASS = "<safe-yaml-loader>"


@dataclass(slots=True)
Expand Down Expand Up @@ -613,7 +615,12 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.visit(base)
for keyword in node.keywords:
self.visit(keyword.value)
self._current_alias_scope()[node.name] = None
is_safe_yaml_subclass = not node.decorator_list and any(
self._is_safe_yaml_loader_reference(base) for base in node.bases
)
self._current_alias_scope()[node.name] = (
SAFE_YAML_SUBCLASS if is_safe_yaml_subclass else None
)
self._current_network_scope()[node.name] = None
self._class_alias_scopes.append({})
self._class_network_instance_scopes.append({})
Expand Down Expand Up @@ -1054,23 +1061,15 @@ def _argument_names(arguments: ast.arguments) -> set[str]:
names.add(arguments.kwarg.arg)
return names

@staticmethod
def _uses_safe_yaml_loader(node: ast.Call) -> bool:
def _uses_safe_yaml_loader(self, node: ast.Call) -> bool:
for keyword in node.keywords:
if keyword.arg in {"Loader", "loader"}:
value = keyword.value
if isinstance(value, ast.Attribute) and value.attr in {
"SafeLoader",
"CSafeLoader",
}:
return True
if isinstance(value, ast.Name) and value.id in {
"SafeLoader",
"CSafeLoader",
}:
return True
return self._is_safe_yaml_loader_reference(keyword.value)
return False

def _is_safe_yaml_loader_reference(self, node: ast.AST) -> bool:
return self._qualified_name(node) in SAFE_YAML_LOADERS | {SAFE_YAML_SUBCLASS}

@staticmethod
def _keyword_is_true(node: ast.Call, keyword_name: str) -> bool:
return any(
Expand Down
6 changes: 6 additions & 0 deletions src/hermes_plugin_guard/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ def _symlink_findings(plugin_root: Path, repository_root: Path) -> list[Finding]
for path in sorted(plugin_root.rglob("*")):
if not path.is_symlink():
continue
try:
relative_parts = path.relative_to(plugin_root).parts
except ValueError:
continue
if _ignored_parts(relative_parts):
continue
try:
path.resolve(strict=True).relative_to(plugin_root.resolve())
except (FileNotFoundError, OSError, ValueError):
Expand Down
56 changes: 54 additions & 2 deletions tests/test_python_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,31 @@ def test_network_alias_tls_listener_and_declared_environment_handling(
assert "HPG105" in ids


def test_safe_yaml_loader_and_regular_subprocess_definition_do_not_overreport(
def test_safe_yaml_loader_and_verified_subclasses_do_not_overreport(
tmp_path: Path,
) -> None:
inspection = _inspect(
tmp_path,
"\n".join(
[
"import yaml",
"from yaml import SafeLoader as ImportedSafeLoader",
"class UniqueSafeLoader(yaml.SafeLoader):",
" pass",
"class ImportedSubclass(ImportedSafeLoader):",
" pass",
"class TransitiveSafeLoader(UniqueSafeLoader):",
" pass",
"class FastSafeLoader(yaml.CSafeLoader):",
" pass",
"def parse(blob):",
" return yaml.load(blob, Loader=yaml.SafeLoader)",
" return [",
" yaml.load(blob, Loader=yaml.SafeLoader),",
" yaml.load(blob, Loader=UniqueSafeLoader),",
" yaml.load(blob, Loader=ImportedSubclass),",
" yaml.load(blob, Loader=TransitiveSafeLoader),",
" yaml.load(blob, Loader=FastSafeLoader),",
" ]",
"",
]
),
Expand All @@ -224,6 +239,43 @@ def test_safe_yaml_loader_and_regular_subprocess_definition_do_not_overreport(
assert "HPG102" not in {finding.rule_id for finding in inspection.findings}


def test_unverified_or_rebound_yaml_loaders_remain_reported(tmp_path: Path) -> None:
inspection = _inspect(
tmp_path,
"\n".join(
[
"import evil",
"import yaml",
"def replace(cls):",
" return object",
"class SafeLoader:",
" pass",
"class UnsafeSubclass(yaml.Loader):",
" pass",
"@replace",
"class DecoratedSafeSubclass(yaml.SafeLoader):",
" pass",
"class ReboundSafeSubclass(yaml.SafeLoader):",
" pass",
"ReboundSafeSubclass = object",
"def parse(blob):",
" return [",
" yaml.load(blob, Loader=SafeLoader),",
" yaml.load(blob, Loader=UnsafeSubclass),",
" yaml.load(blob, Loader=evil.SafeLoader),",
" yaml.load(blob, Loader=DecoratedSafeSubclass),",
" yaml.load(blob, Loader=ReboundSafeSubclass),",
" ]",
"",
]
),
)

unsafe_yaml = [finding for finding in inspection.findings if finding.rule_id == "HPG102"]

assert len(unsafe_yaml) == 5


def test_privileged_surfaces_and_literal_hooks_are_recorded(tmp_path: Path) -> None:
inspection = _inspect(
tmp_path,
Expand Down
14 changes: 14 additions & 0 deletions tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ def test_scanner_does_not_follow_file_symlinks_outside_root(tmp_path: Path) -> N
assert "HPG201" not in {finding.rule_id for finding in result.findings}


def test_symlinks_in_ignored_directories_are_not_reported(tmp_path: Path) -> None:
external = tmp_path / "external-python"
external.write_text("#!/usr/bin/env python\n", encoding="utf-8")
plugin = make_clean_plugin(tmp_path / "plugin")
ignored_bin = plugin / ".venv" / "bin"
ignored_bin.mkdir(parents=True)
(ignored_bin / "python").symlink_to(external)
(ignored_bin / "broken-python").symlink_to(tmp_path / "missing-python")

result = scan(plugin)

assert result.findings == []


def test_excluded_rules_are_removed_after_analysis(risky_plugin: Path) -> None:
result = scan(risky_plugin, excluded_rules=["hpg201", "HPG103"])

Expand Down