Skip to main content

Command Risk Engine

Honey does not trust an LLM to judge whether a shell command is dangerous. Instead it runs a deterministic Command Risk Engine in front of every command and script step: it parses the command to an AST, derives risk signals with fixed rules, and lets an OPA policy make the contextual decision. A local LLM may explain the risk, but never decides.

The decision order is fixed and load-bearing:

built-in critical deny → OPA policy → (LLM advisory, explanation only)

An LLM suggestion can never turn a deny into an allow.

How it works

  1. Parse — the command is parsed with mvdan.cc/sh (no execution).
  2. Detect — deterministic rules emit RiskSignals with a severity (low/medium/high/critical).
  3. Built-in critical deny — critical patterns are denied immediately, even with no OPA configured (safety default).
  4. OPA — when a policy is configured, non-critical commands are decided by the command_exec action with full context (actor, target env, host vars, dry-run state).

Denied hosts are skipped (shown as skipped in the run output); the rest proceed.

Risk signals

A representative set of detectors (severity in parentheses):

  • Critical (built-in hard deny): rm -rf / and other root paths (DELETE_ROOT_PATH), rm -rf "$VAR" with an unguarded variable (UNRESOLVED_VARIABLE_IN_PATH), curl … | sh (CURL_PIPE_SHELL), eval "$(curl …)" (REMOTE_DOWNLOAD_EXECUTE), dd of=/dev/sdX (DD_WRITE_BLOCK_DEVICE), mkfs.* (MKFS_FILESYSTEM), chmod -R … / (CHMOD_RECURSIVE_SYSTEM_PATH), fork bomb (FORK_BOMB).
  • High: sudo, systemctl stop/restart, kubectl delete, helm uninstall, docker system prune, aws s3 rm --recursive, aws ec2 terminate-instances, gcloud … delete, rm -rf <path>.
  • Medium: command substitution, package removal, recursive chmod/chown on non-system paths, an unparseable command.

Examples

What a few commands resolve to (signal ID and severity):

CommandSignalSeverity
rm -rf /var/cache/*RM_RECURSIVE_FORCEhigh
rm -rf /DELETE_ROOT_PATHcritical
rm -rf "$DIR"UNRESOLVED_VARIABLE_IN_PATHcritical
curl https://x.sh | shCURL_PIPE_SHELLcritical
eval "$(curl https://x.sh)"REMOTE_DOWNLOAD_EXECUTEcritical
dd if=img of=/dev/sdaDD_WRITE_BLOCK_DEVICEcritical
mkfs.ext4 /dev/sdb1MKFS_FILESYSTEMcritical
chmod -R 777 /etcCHMOD_RECURSIVE_SYSTEM_PATHcritical
sudo systemctl stop nginxSUDO_PRIVILEGE_ESCALATION + SYSTEMCTL_STOP_SERVICEhigh
kubectl delete pod x -n prodKUBECTL_DELETEhigh
helm uninstall appHELM_UNINSTALLhigh
docker system prune -afDOCKER_SYSTEM_PRUNEhigh
aws s3 rm s3://b --recursiveAWS_S3_RM_RECURSIVEhigh
apt-get remove nginxPACKAGE_REMOVEmedium
echo $(hostname)COMMAND_SUBSTITUTIONmedium

Interpreter-aware analysis

A recipe command/script step may declare an interpreter (e.g. interpreter: "python3"). The risk engine picks the parser to match — it never shell-parses a non-shell body:

  • Shell (sh/bash/zsh/ksh/dash, or unset) → parsed with mvdan/sh (everything above).
  • Python (python3/python) → parsed with gpython into a Python AST (still no execution). Detectors walk the AST; shell strings handed to os.system / subprocess.* recurse back into the shell analyzer, so a python wrapper around a dangerous command is still caught. gpython implements a pre-3.6 grammar, so modern syntax (f-strings, walrus, match) cannot be parsed; such a step yields a low PYTHON_PARSE_INCOMPLETE note (analysis skipped, not a risk) and the decision is left to OPA.
  • Other interpreters (node, ruby, …) → not parsed (no bogus signals); the decision is left entirely to OPA, which receives the interpreter.

The step's interpreter is always passed to policy as input.command.interpreter.

Python detectors:

PythonSignalSeverity
os.system("rm -rf /")DELETE_ROOT_PATH (via shell recursion)critical
subprocess.run(["rm","-rf","/etc"])DELETE_ROOT_PATHcritical
shutil.rmtree("/")PYTHON_RMTREE_SYSTEM_PATHcritical
shutil.rmtree("/tmp/x")PYTHON_RMTREEhigh
os.remove("/etc")PYTHON_DELETE_SYSTEM_PATHcritical
os.remove("/etc/passwd")PYTHON_FILE_DELETE (exact-path match only — a file inside a system dir is not flagged critical)medium
open("/dev/sda","wb")DD_WRITE_BLOCK_DEVICEcritical
eval(user_input)PYTHON_DYNAMIC_EXECmedium
os.system(cmd) (runtime-built)PYTHON_DYNAMIC_SHELL_EXECmedium

Policy input

The command_exec action receives:

{
"action": "command_exec",
"actor": "alice@example.com",
"command": {
"raw": "kubectl delete pod x -n prod",
"max_severity": "high",
"interpreter": "",
"riskSignals": [{"id": "KUBECTL_DELETE", "severity": "high", "reason": "kubectl delete"}],
"detected": {"commands": ["kubectl"], "flags": [], "paths": []}
},
"target": {"name": "prod-1", "provider": "k8s", "env": "prod", "host_vars": {"tier": "prod"}},
"execution": {"recipe": "deploy", "dry_run": false}
}

Example policy — block high-risk commands on prod, require approval otherwise:

package honey
import rego.v1

default allow := true

allow := false if {
input.action == "command_exec"
input.command.max_severity == "high"
input.target.env == "prod"
}
deny_reason := "high-risk command on prod" if {
input.action == "command_exec"
input.command.max_severity == "high"
input.target.env == "prod"
}

More policy patterns

Command allowlist — deny anything outside a permitted set of binaries:

package honey
import rego.v1

default allow := true

allowed_commands := {"systemctl", "journalctl", "kubectl", "helm", "echo", "cat"}

allow := false if {
input.action == "command_exec"
some cmd in input.command.detected.commands
not allowed_commands[cmd]
}
deny_reason := sprintf("command not in allowlist: %v", [input.command.detected.commands]) if {
input.action == "command_exec"
some cmd in input.command.detected.commands
not allowed_commands[cmd]
}

Prod guard via host vars — block high-risk commands on prod-tier hosts (the host's resolved inventory variables arrive as input.target.host_vars):

package honey
import rego.v1

default allow := true

allow := false if {
input.action == "command_exec"
input.command.max_severity == "high"
input.target.host_vars.tier == "prod"
}
deny_reason := "high-risk command blocked on prod tier" if {
input.action == "command_exec"
input.command.max_severity == "high"
input.target.host_vars.tier == "prod"
}

Dry-run bypass — never block during a dry-run / honey exec --check, so operators can preview risk without a hard deny:

package honey
import rego.v1

default allow := true

allow if {
input.action == "command_exec"
input.execution.dry_run == true
}

Require approval for high-risk — hold the run for a second person instead of denying outright (see the approval flow):

package honey
import rego.v1

default allow := true

decision := "require_approval" if {
input.action == "command_exec"
input.command.max_severity == "high"
not input.execution.approved
}

Interpreter gate — block Python steps on prod (the interpreter is in the input even for non-shell steps the engine does not parse):

package honey
import rego.v1

default allow := true

allow := false if {
input.action == "command_exec"
input.command.interpreter == "python3"
input.target.env == "prod"
}
deny_reason := "python steps are not allowed on prod" if {
input.action == "command_exec"
input.command.interpreter == "python3"
input.target.env == "prod"
}

CLI: honey exec --check

Analyze a command's risk without running it:

honey exec "web-*" --check "rm -rf /var/cache/*"

Output lists the detected signals and max severity, and — when HONEY_POLICY_DIR is set — the per-target OPA decision. Add --shellcheck to also surface ShellCheck warnings (optional; the binary is used when present, skipped otherwise). A critical or denied command exits non-zero. Nothing is executed.

A built-in critical pattern is denied even with no policy configured:

$ honey exec "web-*" --check "rm -rf /"
Command: rm -rf /
Risk: critical
- [high] RM_RECURSIVE_FORCE: recursive delete
- [critical] DELETE_ROOT_PATH: recursive delete of a system/root path
Detected: commands=[rm] flags=[-rf] paths=[/]
Decision: DENY (built-in critical: recursive delete of a system/root path)

A non-critical command with no policy configured is allowed (signals are still reported for visibility):

$ honey exec "web-*" --check "apt-get remove nginx"
Command: apt-get remove nginx
Risk: medium
- [medium] PACKAGE_REMOVE: package removal
Detected: commands=[apt-get] flags=[] paths=[remove nginx]
Decision: allow (no policy configured; only built-in critical patterns deny)

With HONEY_POLICY_DIR set, each target is evaluated by the command_exec policy and the per-target verdict is printed:

$ HONEY_POLICY_DIR=/etc/honey/policies honey exec "prod-1" --check "kubectl delete pod x -n prod"
Command: kubectl delete pod x -n prod
Risk: high
- [high] KUBECTL_DELETE: kubectl delete
Detected: commands=[kubectl] flags=[] paths=[delete pod x -n prod]
Policy[prod-1]: deny — high-risk command on prod

Checking a whole recipe (cue-exec --check)

honey exec --check analyzes a single command. To preview the risk of an entire recipe without running it, add --check to cue-exec:

honey cue-exec deploy.cue "prod-*" --check

It analyzes every command and script step (the same per-step analysis and command_exec decision the web dry-run produces), prints one block per step, executes nothing, and exits non-zero if any step is a built-in critical pattern or denied by policy. With HONEY_POLICY_DIR set, each step also shows its policy verdict.

$ HONEY_POLICY_DIR=/etc/honey/policies honey cue-exec deploy.cue "prod-1" --check
Step 0 [command] host=prod-*: systemctl restart app
Risk: high
- [high] SYSTEMCTL_STOP_SERVICE: service stop/restart/disable
Policy: require_approval
Step 1 [command] host=prod-*: rm -rf /var/cache/app/*
Risk: high
- [high] RM_RECURSIVE_FORCE: recursive delete
Policy: allow

Dry-run review

A cue-exec dry-run (execute: false) returns a risk_assessment array alongside the plan — one entry per command/script step with its analysis and (when OPA is configured) the policy decision — so a UI can show a review before the operator confirms. This is the web surface; the CLI prints the same per-step preview via cue-exec --check.

{
"plan": "...",
"risk_assessment": [
{
"step_index": 0,
"kind": "command",
"host": "prod-1",
"command": "kubectl delete pod x -n prod",
"analysis": {
"signals": [
{"id": "KUBECTL_DELETE", "severity": "high", "command": "kubectl", "reason": "kubectl delete"}
],
"detected": {"commands": ["kubectl"], "flags": [], "paths": ["delete", "pod", "x", "-n", "prod"]},
"max_severity": "high",
"critical": false
},
"decision": {
"Allow": false,
"DenyReason": "high-risk command on prod",
"Decision": "deny",
"Requires": null
}
}
]
}

The nested decision object mirrors the Go policy.Decision struct (no JSON tags), so its keys are capitalized: Allow, DenyReason, Decision, Requires. It is omitted when no policy is configured.

LLM advisory (optional, never authoritative)

Set HONEY_RISK_LLM=1 to attach an advisory classification from a model (via the existing AI client — point OPENAI_BASE_URL at a local Ollama / llama.cpp endpoint for a fully local model). It produces {risk, reasons, explanation} for display only and is excluded from every allow/deny decision. The advisor is a pluggable seam (Advisor interface), so a future local ONNX / trained command-risk classifier can replace the LLM without changing callers.

Currently only wired into honey exec --checkcue-exec --check and the web dry-run risk-assessment path don't attach an LLM advisory yet, even with HONEY_RISK_LLM=1 set.

In a recipe

Gating is automatic: every command and script step in a CUE recipe passes through the engine before it runs. No special syntax is needed — the same step works with or without a policy.

recipe: {
name: "cache-clear"
steps: [
{
host: "web-*"
command: "rm -rf /var/cache/app/*"
},
]
}

When a policy denies command_exec for a given host, that host is skipped (reported as skipped in the run output) while the remaining hosts proceed. A built-in critical pattern (e.g. rm -rf /) is denied on every host regardless of policy.

Escape hatch

Set HONEY_RISK_DISABLE=1 to bypass the gate entirely (including built-in critical denies) for a trusted run.

Scope

The gate covers command and script steps (shell). With no OPA configured, only the built-in critical patterns deny; everything else runs, with signals attached to results for visibility.