Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 6 additions & 1 deletion docs/guard/command-remote-extension-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ Guard's built-in remote administration extensions use parsed executables, leadin
- Portable `.cmd` and `.exe` launcher names
- Rsync `--dry-run` and `-n` safe variants, including bundled short flags
- SSH inspection, query, control, and no-command modes
- essh command execution fanned out across an entire host group
- essh removal of cached hosts, cached keys, and saved workspaces

Interactive SSH connections remain outside the explicit remote-execution rule. Ordinary rsync copies without deletion flags also remain outside the destructive synchronization rule.
Interactive SSH connections remain outside the explicit remote-execution rule. Ordinary rsync copies without deletion flags also remain outside the destructive synchronization rule. essh read verbs — `connect`, `why`, and the `list` and `show` forms of hosts, keys, and workspaces — also remain outside these rules.

`essh run` is treated as a whole-subcommand match rather than an operand count. Unlike `ssh`, which opens an interactive session when no command follows the destination, `essh run` has no interactive form: the subcommand exists only to execute a command across a host group, so every invocation is remote execution. Its blast radius is the group membership rather than a single destination, which is why it carries a higher severity than the single-host SSH execution rule.

## References

- [OpenSSH client](https://man.openbsd.org/ssh)
- [OpenSSH client configuration](https://man.openbsd.org/ssh_config)
- [OpenSSH secure copy client](https://man.openbsd.org/scp)
- [Rsync manual](https://rsync.samba.org/ftp/rsync/rsync.1.html)
- [essh](https://github.com/matthart1983/essh)
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
"sqlite destructive command": ("destructive_shell",),
"supabase destructive command": ("destructive_shell", "network_egress"),
"rsync remote shell command": ("execution", "network_egress"),
"essh group execution command": ("execution", "network_egress"),
"essh cache removal command": ("destructive_shell",),
**GITHUB_ACTION_RISK_CLASSES,
}
_GIT_GLOBAL_OPTIONS_WITH_VALUES = frozenset(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from .command_database_matchers import LeadingSubcommandMatcher
from .command_extension_matchers import executable_names, safe_flag_variant
from .command_extension_specs import CommandExtensionSpec
from .command_rules import (
Expand Down Expand Up @@ -185,6 +186,24 @@
)


_ESSH_GLOBAL_OPTIONS = frozenset({"--theme"})
_ESSH_GROUP_EXECUTION = LeadingSubcommandMatcher(
executables=executable_names("essh"),
subcommands=("run",),
options_with_values=_ESSH_GLOBAL_OPTIONS,
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
)
_ESSH_CACHE_REMOVAL = AnyMatcher(
matchers=tuple(
LeadingSubcommandMatcher(
executables=executable_names("essh"),
subcommands=subcommands,
options_with_values=_ESSH_GLOBAL_OPTIONS,
)
for subcommands in (("hosts", "remove"), ("keys", "remove"), ("workspace", "remove"))
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _remote_rule(
*,
rule_id: str,
Expand Down Expand Up @@ -284,6 +303,33 @@ def _remote_rule(
),
),
),
_remote_rule(
rule_id="command.remote.essh.group-execution",
example_command="essh run web -- sudo systemctl restart api",
title="essh host group execution",
description="Identifies essh run invocations that execute a command across every host in a group.",
matcher=_ESSH_GROUP_EXECUTION,
action_class="essh group execution command",
safer_alternative=(
"Inspect the group membership with essh hosts list and run the command against a single host first."
),
severity="critical",
risk_classes=("execution", "network_egress"),
),
_remote_rule(
rule_id="command.remote.essh.cache-removal",
example_command="essh keys remove deploy-key",
title="essh cached credential and host removal",
description="Identifies essh remove verbs that delete cached hosts, keys, or saved workspaces.",
matcher=_ESSH_CACHE_REMOVAL,
action_class="essh cache removal command",
safer_alternative=(
"List the cached entry first with essh hosts list, essh keys list, or essh workspace list "
"and confirm the name before removing it."
),
severity="high",
risk_classes=("destructive_shell",),
),
)


Expand Down Expand Up @@ -315,4 +361,16 @@ def _remote_rule(
safer_alternatives=("Use --dry-run and inspect the itemized change list before applying deletions.",),
reference_urls=("https://rsync.samba.org/ftp/rsync/rsync.1.html",),
),
CommandExtensionSpec(
extension_id="command.remote.essh",
name="essh group execution and cache removal protection",
description=(
"Reviews essh invocations that execute commands across a host group or delete cached "
"hosts, keys, and workspaces."
),
action_classes=("essh group execution command", "essh cache removal command"),
risk_classes=("destructive_shell", "execution", "network_egress"),
safer_alternatives=("Inspect group membership and cached entries before running or removing anything.",),
reference_urls=("https://github.com/matthart1983/essh",),
),
)
15 changes: 11 additions & 4 deletions tests/fixtures/extension-controls/catalog-baseline.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"canonical_diff_digest",
"effective"
],
"catalog_digest": "7fd8f8affd5163d1b8b307e6253e3f0184b3ac239ab0b0eb5142a1e69b496220",
"catalog_digest": "5e4a0807d290f0075d4b34bcd16e8800818f4757e2482a3956ebf5d9164de206",
"control_schema_version": "1.0.0",
"daemon_api_schema": "guard.daemon.extension-controls.v1",
"effective_shape": [
Expand All @@ -21,7 +21,7 @@
"failures",
"projection"
],
"extension_count": 60,
"extension_count": 61,
"extension_ids": [
"command.api-gateway",
"command.backup.borg",
Expand Down Expand Up @@ -71,6 +71,7 @@
"command.platform.heroku",
"command.platform.netlify",
"command.platform.vercel",
"command.remote.essh",
"command.remote.rsync",
"command.remote.scp",
"command.remote.ssh",
Expand All @@ -87,7 +88,7 @@
"extension_schema_version": 2,
"installed_browser_config": "dashboard/playwright.installed.config.ts",
"overview_route": "/extensions",
"permission_count": 158,
"permission_count": 160,
"permission_examples": {
"command.api-gateway.permission.delete": "aws apigateway delete-rest-api",
"command.backup.borg.permission.mutation": "borg delete",
Expand Down Expand Up @@ -205,6 +206,8 @@
"command.platform.netlify.permission.site-deletion": "netlify sites:delete",
"command.platform.vercel.permission.deletion": "vercel remove",
"command.platform.vercel.permission.production-change": "vercel promote",
"command.remote.essh.permission.cache-removal": "essh keys remove deploy-key",
"command.remote.essh.permission.group-execution": "essh run web -- sudo systemctl restart api",
"command.remote.rsync.permission.deletion": "rsync --del",
"command.remote.rsync.permission.remote-shell": "rsync --rsync-path",
"command.remote.scp.permission.transfer": "scp secrets.tar.gz backup-host:/srv/backup/",
Expand Down Expand Up @@ -437,6 +440,8 @@
"command.platform.netlify.permission.site-deletion",
"command.platform.vercel.permission.deletion",
"command.platform.vercel.permission.production-change",
"command.remote.essh.permission.cache-removal",
"command.remote.essh.permission.group-execution",
"command.remote.rsync.permission.deletion",
"command.remote.rsync.permission.remote-shell",
"command.remote.scp.permission.transfer",
Expand Down Expand Up @@ -490,7 +495,7 @@
"controls",
"semantic_preview"
],
"rule_count": 146,
"rule_count": 148,
"rule_ids": [
"command.api-gateway.delete",
"command.backup.borg.mutation",
Expand Down Expand Up @@ -596,6 +601,8 @@
"command.platform.netlify.production-deploy",
"command.platform.vercel.deletion",
"command.platform.vercel.production-change",
"command.remote.essh.group-execution",
"command.remote.essh.cache-removal",
"command.remote.rsync.remote-shell",
"command.remote.rsync.deletion",
"command.remote.scp.transfer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
"source-4c6cd49f04a9d5846ed493d7": "6ffc8b2eaa7f9d3062abca728b0ae50352635058a2a4e541e5fa83aeee8e483e",
"source-4d5b41d371e44c13f67f23dc": "76172bab242caa14c7e53932a519aa04e37d84ff13db0dc70b6a979323f35078",
"source-4ef94ac221385f7511fc5bcb": "19ced6d8056b18599d6adf48220b57133124eea3629a07a794fd2249510ec07c",
"source-4f04975a34d46dcd67c9efcd": "95cbe2765f77664f6513cbbc9f7f2adaba06e0b843869bf35bde9344c7b03e5d",
"source-4f04975a34d46dcd67c9efcd": "cf63160ce091b9771a1c03bd9faa9cdd211c28c174b4518000ce374b0490ad30",
"source-4f788ab6505389feb6b56cc0": "e2c4bc96884d7ea90c5843a9b89ff21db360a4f7fa84d56ba8407937996da916",
"source-4f7f80b473aa970e68ced9bd": "079a209a67ab801d14d5b74f030e8025253b83721a84cdfef02c0c4ba83f59a9",
"source-4fb488426afd2d8ca7bc9a18": "5d82cd4f71c5311d58cbfacc8299c4376c24bb02ff222acff789c7e79b0a7402",
Expand Down Expand Up @@ -325,7 +325,7 @@
"source-e1e3ba2136aa4a1ad13ed7b0": "2e94d7ecc093c0db50c1192de637b199ff75e0c1eb791c6f8dfbac303d605353",
"source-e31d22ca92b4aad1eac7c5d8": "9d16c2628d68ff812dc8027119ef4250b7ff44781b7f01232f0fab6ec5d5031e",
"source-e4a25e12163ae68c101bcc17": "f8656c3eb136e7985b7e4d6467d32740a5d72e761e16006d80f17b2e5a073be9",
"source-e5092effd9878194fc7b1756": "da758a9e849dc15bda659904e63434ada07f1e763448c75e059345e5e28bca3a",
"source-e5092effd9878194fc7b1756": "9b8ebcf8f61da631838b31276276ec1aed5633afa67aa49b2dede2238550fb77",
"source-e5ba5048b35cbfbc904173cd": "932581669b068054176699fc27bc21ddd59f327fb85af33086952399cef36bb2",
"source-e5c394cae3e8083cb2617097": "775aea98a7e40d0502e14120d31eff25e206a5cade7168337ed254b320510d4c",
"source-e61ec49b032b12089dfeafdb": "57b66c38b0f98e6125ce7edaf7a7e3e164f91395979903275f346f00571e6aae",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_guard_command_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def test_command_extension_registry_is_deterministic_and_complete() -> None:
assert BUILT_IN_COMMAND_EXTENSION_REGISTRY.rule_for_action_class("destructive shell command") is not None
assert BUILT_IN_COMMAND_EXTENSION_REGISTRY.for_action_class("GitHub merge command") is not None
assert BUILT_IN_COMMAND_EXTENSION_REGISTRY.rule_for_action_class("GitHub merge command") is not None
assert sum(extension["rule_count"] for extension in payload["extensions"]) == 146
assert sum(extension["rule_count"] for extension in payload["extensions"]) == 148


@pytest.mark.parametrize(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_guard_command_permission_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def test_permission_catalog_serialization_and_digest_are_deterministic() -> None
reversed_registry = CommandSafetyExtensionRegistry(tuple(reversed(registry.extensions)))

assert reversed_registry.catalog_digest == registry.catalog_digest
assert registry.catalog_digest == "7fd8f8affd5163d1b8b307e6253e3f0184b3ac239ab0b0eb5142a1e69b496220"
assert registry.catalog_digest == "5e4a0807d290f0075d4b34bcd16e8800818f4757e2482a3956ebf5d9164de206"
assert [permission.permission_id for permission in registry.permissions] == sorted(
permission.permission_id for permission in registry.permissions
)
Expand Down
57 changes: 56 additions & 1 deletion tests/test_guard_command_remote_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,38 @@
"Rsync remote shell command",
"command.remote.rsync.remote-shell",
),
(
"essh run web -- sudo systemctl restart api",
"essh group execution command",
"command.remote.essh.group-execution",
),
(
"essh --theme dark run web -- uptime",
"essh group execution command",
"command.remote.essh.group-execution",
),
(
"essh --theme=dark run web -- uptime",
"essh group execution command",
"command.remote.essh.group-execution",
),
(
"essh.exe run web -- uname -a",
"essh group execution command",
"command.remote.essh.group-execution",
),
("essh hosts remove web-1", "essh cache removal command", "command.remote.essh.cache-removal"),
("essh keys remove deploy-key", "essh cache removal command", "command.remote.essh.cache-removal"),
(
"essh workspace remove production",
"essh cache removal command",
"command.remote.essh.cache-removal",
),
(
"essh.cmd --theme nord keys remove deploy-key",
"essh cache removal command",
"command.remote.essh.cache-removal",
),
)


Expand Down Expand Up @@ -135,6 +167,19 @@ def test_remote_rules_feed_runtime_hooks(tmp_path: Path) -> None:
"rsync -av --delete ./out/ host.example:/srv/app/ --no-dry-run -n",
"grep 'ssh host command|scp source target|rsync --delete' docs",
"echo ssh host.example uptime",
"essh connect web-1",
"essh connect remove",
"essh hosts list",
"essh hosts add web-1",
"essh keys list",
"essh workspace list",
"essh workspace show production",
"essh workspace save production web-1 web-2",
"essh why web-1",
"essh session list",
"essh audit",
"grep 'essh run web -- uptime' docs",
"echo essh keys remove deploy-key",
)


Expand All @@ -153,7 +198,12 @@ def test_rsync_disabled_preview_aliases_remain_live_execution(tmp_path: Path) ->


def test_remote_extensions_publish_official_references() -> None:
for extension_id in ("command.remote.ssh", "command.remote.scp", "command.remote.rsync"):
for extension_id in (
"command.remote.ssh",
"command.remote.scp",
"command.remote.rsync",
"command.remote.essh",
):
extension = BUILT_IN_COMMAND_EXTENSION_REGISTRY.get(extension_id)

assert extension is not None
Expand Down Expand Up @@ -207,6 +257,11 @@ def test_remote_execution_actions_publish_risk_classes() -> None:
"execution",
"network_egress",
)
assert risk_classes_for_command_action("essh group execution command") == (
"execution",
"network_egress",
)
assert risk_classes_for_command_action("essh cache removal command") == ("destructive_shell",)


def test_rsync_option_values_cannot_forge_dry_run(tmp_path: Path) -> None:
Expand Down