plugins/claude-code-hermit/skills/docker-security/SKILL.md
Opt-in advanced wizard for Docker security hardening beyond v1.0.26 baseline. Adds LAN containment with DNS policy (firewall + DNS sidecar), resource bounds with kernel hygiene sysctls, and a boot-time plugin install audit log. Each toggle is opt-in with honest cost/benefit framing, applied as a docker-compose overlay (does not modify the base compose file), verified against the live container, and fully reversible. Run after /docker-setup; requires bridge networking.
npx skillsauth add gtapps/claude-code-hermit docker-securityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Advanced hardening for already-deployed hermit containers. Each toggle is opt-in, presented with honest cost/benefit framing, and applied as a docker-compose.security.yml overlay that the hermit-docker wrapper auto-detects. Reversal: re-run and answer No to every prompt, or delete docker-compose.security.yml directly.
Tone: Honest about tradeoffs. Tell operators what each toggle does AND does not protect. Do not oversell.
Important: Run all checks and commands sequentially — do not use parallel tool calls.
Templates live in ${CLAUDE_SKILL_DIR}/../../state-templates/docker/security/.
Installing a plugin runs that plugin's hooks and skills with the same authority as hermit. The container hardening here reduces what a compromised plugin can do to the host kernel and your local network — it does not vet the plugin's intent, sandbox its file access within the project, or prevent it from acting on your behalf via the agent. A malicious or careless plugin still runs as you.
Honest limitation: DNS policy below blocks domain-based exfil/C2 but cannot stop direct-IP egress to a hardcoded public address. A future release may add nftset-driven IP allowlisting.
This skill is host-only — it writes a docker-compose.security.yml overlay on the host and recreates the container with stronger isolation.
Run: [ -f /.dockerenv ] || [ -f /run/.containerenv ] && echo container || echo host
If the output is container, stop immediately — do not proceed to step 1. Print:
This skill writes a
docker-compose.security.ymloverlay on the host and recreates the container with stronger isolation. Run it from your host shell in the project root. To inspect the live security posture inside the running container, run/claude-code-hermit:hermit-doctor— it includes adocker-securitycheck.
Read .claude-code-hermit/config.json. If missing: "Run /claude-code-hermit:hatch first." Stop.
Verify docker-compose.hermit.yml exists at the project root. If missing: "Run /claude-code-hermit:docker-setup first." Stop.
Docker daemon check: run timeout 10s docker info >/dev/null 2>&1. If it fails or times out: tell the operator "Docker daemon is not reachable or timed out. Start Docker before re-running /docker-security." Stop.
Read docker.network_mode from config (default: "bridge"). If "host", set HOST_NETWORK_MODE=true and surface to operator: "Detected network_mode: host in your config. The LAN containment toggle (Prompt 1) will be skipped — it would replace host mode and break your HA / host-bound service access. Resource bounds (Prompt 2) will not apply network sysctls in host mode either; Docker rejects them and the container would fail to start."
Detect hermit ports: run timeout 10s docker compose -f docker-compose.hermit.yml config --format json 2>/dev/null and parse .services.hermit.ports. Store as in-memory detected_hermit_ports (array of long-form Compose port objects: {target, published, host_ip, protocol, mode}). If the command fails or returns no JSON, fall back to grep -n '^\s*ports:' docker-compose.hermit.yml and set detected_hermit_ports_unparsed=true if found. Either way, a non-empty result means ports are present.
Check whether the container is currently running: docker compose -f docker-compose.hermit.yml ps --status running --format '{{.Service}}' 2>/dev/null | grep -x hermit. If absent: tell the operator "Container is not running. The wizard will still write the overlay; you'll see the live verification only after starting the container with hermit-docker up."
Read current docker.security.* from config.json. Also read sandbox.* from the target settings file (same hatch_target routing as hatch/docker-setup: hatch_target == "local" → .claude/settings.local.json; else → .claude/settings.json). Print a "current posture" summary. Example:
Current security posture:
LAN containment: off
Resource bounds: off
Audit log: off
Baseline (always on, from v1.0.26):
cap_drop: ALL, no-new-privileges, pids_limit: 2048
Sandbox (bash tool isolation, from v1.1.2):
profile: standard (or: off / not configured)
nested-sandbox: auto (hermit-start writes enableWeakerNestedSandbox in container)
Derive profile by checking sandbox.enabled in the settings file: true → standard (or the profile name if you can infer it from filesystem.denyRead), false → off, absent → not configured.
Print the trust-model paragraph from the top of this file (the blockquote). Then continue.
If HOST_NETWORK_MODE is true, do not present this prompt. Tell the operator: "Skipping LAN containment — incompatible with network_mode: host. Run /claude-code-hermit:docker-setup to switch to bridge mode if you want this hardening." Continue to step 4.
Otherwise, ask with AskUserQuestion (header: "LAN containment"):
question: "Add LAN containment with DNS policy? (firewall sidecar + DNS allowlist)"
options:
- label: "Yes — recommended (LAN block + DNS log-only)"
description: "Blocks RFC1918 + cloud metadata. DNS in log-only mode: allows everything, logs blocked queries for tuning."
- label: "Yes — strict (LAN block + DNS enforce)"
description: "Same LAN block. DNS returns NXDOMAIN for any domain not on the allowlist."
- label: "No"
description: "Skip this toggle."
Map: "Yes — recommended" → dns_mode: "log-only". "Yes — strict" → dns_mode: "enforce". "No" → skip to step 4.
If the operator selected a "Yes" option AND detected_hermit_ports is non-empty (or detected_hermit_ports_unparsed=true), surface a follow-up AskUserQuestion (header: "Port publishing") immediately:
Your docker-compose.hermit.yml publishes ports on the hermit service:
- "3000:3000" ← list each port if parsed, or "(port block detected)" if unparsed
...
LAN containment requires hermit to share hermit-netguard's network namespace.
Docker forbids `ports:` on a container that joins another container's netns —
only netguard (the netns owner) can publish ports.
Options:
"Move ports to netguard (recommended)" — render the same ports: block on hermit-netguard in the overlay. Persist to docker.security.network.publish_ports. After overlay is written (step 6), show a diff and prompt the operator to delete the ports: block from docker-compose.hermit.yml themselves — the wizard does not modify the base file."Skip LAN containment (keep ports on hermit)" — clear the LAN containment selection (treat as if operator answered No above). Set lan_containment_skipped_due_to_ports=true. Continue to step 4."Cancel" — print "Re-run /docker-security after deciding how to handle your port publishing. See docs/docker-security.md for guidance." Exit. Write nothing.If operator chose "Skip LAN containment": proceed to step 4 without any LAN containment config. Step 9 final-report notes: "LAN containment skipped — docker-compose.hermit.yml has a ports: block on hermit that is incompatible with network_mode: service:hermit-netguard."
Scan installed fleet plugins. From the host:
claude plugin list --json 2>/dev/null
Apply the project-or-local + enabled filter:
enabled == true AND (scope == "project" OR scope == "local") AND projectPath equals the current project root.For each surviving entry whose plugin name (substring of id left of @) matches *-hermit* and is NOT claude-code-hermit:
installPath field from the JSON.<plugin>/skills/hatch/SKILL.md and look for a ## Docker network requirements heading. Also check <plugin>/DOCKER.md. Stop at the next ## heading.### Domains (DNS allowlist) — bullet entries are domain names. Validate against ^[a-z0-9][a-z0-9.-]+$. Reject failing entries with a warning.### LAN allowlist suggestions — bullet entries are either IPv4 CIDRs (validate with regex) OR the special token ASK_OPERATOR_FOR_HA_IP (or other ASK_OPERATOR_FOR_*_IP patterns). Reject failing CIDRs.If the deduped fleet list is non-empty, present plain text first:
Fleet plugins request these network exceptions:
api.strava.com — claude-code-fitness-hermit (DNS)
www.strava.com — claude-code-fitness-hermit (DNS)
ASK_OPERATOR_FOR_HA_IP — claude-code-homeassistant-hermit (LAN, will prompt for IP)
Then ask once with AskUserQuestion (header: "Fleet network entries"):
"Include all" (Recommended)"Pick each""Skip all"On "Pick each": loop through each entry with a 2-option AskUserQuestion ("Include" / "Skip").
For every confirmed ASK_OPERATOR_FOR_HA_IP (or similar): immediately follow up with AskUserQuestion (header: "<plugin> IP") — Other field accepting an IPv4 address or CIDR. Validate. Re-prompt on invalid. Replace the token with the typed value.
Confirmed entries are added to in-memory fleet_lan_allowlist and fleet_domains for step 6.
If the fleet list is empty: skip silently.
AskUserQuestion (header: "Extra LAN carve-outs"):
"None""Add IPs/ranges" (Other field, comma-separated, IPv4 CIDR validated; re-prompt on invalid)Persist to lan_allowlist (combined with fleet_lan_allowlist).
AskUserQuestion (header: "Extra domains"):
"None (use defaults + fleet)""Add domains" (Other field, comma-separated, domain regex validated; re-prompt on invalid)Persist to additional_domains (combined with fleet_domains).
docker.security.network = {
"enabled": true,
"dns_mode": "log-only" | "enforce",
"lan_allowlist": ["192.168.1.50", ...],
"additional_domains": ["api.strava.com", ...]
}
AskUserQuestion (header: "Resource bounds"):
question: "Set memory and CPU caps + network kernel hardening sysctls?"
options:
- label: "Yes — defaults (mem 4g, cpus 2.0, sysctl bundle)"
description: "Caps a runaway agent's resource cost; sysctls disable ICMP redirect / source-routing tricks."
- label: "Yes — custom mem/cpu"
description: "Pick your own memory and CPU limits."
- label: "No"
description: "Skip this toggle."
On "Yes — custom mem/cpu": follow up with AskUserQuestion (header: "Custom limits") — Other field accepting two values, comma-separated. Validate mem against ^[0-9]+(g|m)?$, cpus against ^[0-9.]+$. Re-prompt on invalid.
Sysctl placement note:
HOST_NETWORK_MODE is true: silently set sysctls_enabled = false regardless of choice and tell the operator: "Network sysctls cannot be applied with network_mode: host — Docker rejects them. Resource bounds (mem/cpus) will still apply if you said Yes."sysctls_enabled = true.Persist:
docker.security.resources = {
"enabled": true,
"mem_limit": "4g",
"memswap_limit": "4g",
"cpus": 2.0,
"sysctls_enabled": true|false
}
AskUserQuestion (header: "Audit log"):
question: "Record every boot-time plugin install to a JSONL audit log?"
options:
- label: "Yes — recommended"
description: "Appends one JSONL line per `claude plugin install` performed by the entrypoint to .claude-code-hermit/state/plugin-installs.jsonl. Honest scope: post-boot installs run via tmux are not captured. The log is empty until something actually installs — on subsequent boots when the marketplace + plugin set is unchanged, no install fires and no entry is written. That's normal, not a failure."
- label: "No"
description: "Skip this toggle."
Persist docker.security.audit.plugin_installs accordingly.
Before rendering — preserve publish_ports across reruns:
Read docker.security.network.publish_ports from the existing config.json (if present). Keep this as persisted_publish_ports. In the current wizard run:
persisted_publish_ports with the newly detected ports from step 5 (prerequisites).persisted_publish_ports unchanged (the operator already deleted the base block; do not lose the netguard mapping).persisted_publish_ports = [] (LAN containment is off; ports stay on hermit in the base file).All-off branch — warn before deletion if publish_ports was set:
Otherwise (all toggles off): before deleting the overlay, check if persisted_publish_ports is non-empty. If so, surface:
Heads up: turning all toggles off will remove the overlay, including these
ports that hermit-netguard currently publishes:
- "3000:3000"
...
If you previously deleted the `ports:` block from docker-compose.hermit.yml,
you'll need to re-add it there before bringing the container back up — the
overlay won't be there to publish them.
Then AskUserQuestion (header: "Confirm all-off"):
"Yes — proceed (I'll restore base ports if needed)""No — cancel"On Yes: continue to overlay deletion, clear docker.security.*, tell the operator "All toggles off — overlay removed and config cleared." Skip to step 9.
On No: exit wizard, write nothing.
If persisted_publish_ports is empty: proceed directly to deletion without the prompt.
Subnet auto-detection (only if LAN containment is enabled):
Run the following to enumerate occupied subnets across all Docker networks (not filtered to bridge — overlap is address-space-based):
timeout 10s docker network ls --format '{{.Name}}' 2>/dev/null | while read net; do
timeout 5s docker network inspect "$net" \
--format '{{range .IPAM.Config}}{{.Subnet}}{{end}}|||{{json .Labels}}' 2>/dev/null
done
Parse each output line into a subnet string and a Labels JSON object. Skip lines without IPv4 subnets.
Exclude networks belonging to this project's own hermit-net from the collision list. Identify these by labels:
com.docker.compose.project == <our project name> AND com.docker.compose.network == "hermit-net"Derive <our project name> from timeout 10s docker compose -f docker-compose.hermit.yml config --format json 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))" — fall back to basename "$PROJECT_DIR" if the command fails.
Use Python ipaddress.ip_network for overlap checking. Walk candidate /24 subnets in order: 172.28.0.0/24, 172.29.0.0/24, 172.30.0.0/24, 172.31.0.0/24, 10.244.0.0/24, 10.245.0.0/24, 10.246.0.0/24, 10.247.0.0/24. Pick the first that doesn't overlap any occupied subnet. Treat parse failures as "subnet unknown, skip."
If all candidates collide, AskUserQuestion (header: "Custom subnet") with Other field accepting an IPv4 /24 CIDR (reject if prefix != 24; re-prompt on invalid or still-colliding).
Compute chosen_gateway = <base>.0.1, chosen_netguard_ip = <base>.0.2. Persist to docker.security.network:
{
"enabled": true,
"dns_mode": "log-only" | "enforce",
"subnet": "172.28.0.0/24",
"gateway": "172.28.0.1",
"netguard_ip": "172.28.0.2",
"lan_allowlist": [...],
"additional_domains": [...],
"publish_ports": [...]
}
If LAN containment is not enabled, omit subnet, gateway, netguard_ip from the docker.security.network object.
If at least one toggle is enabled, render the overlay. Otherwise (all-off): handled in the all-off branch above.
Render 100 directly into nftables.conf as the dnsmasq UID — no probe step. Rationale and recovery path if Alpine ever changes it: see docs/docker-security.md#design-rationale.
After the netguard files are rendered (step 6c), the wizard runs an explicit --no-cache build of hermit-netguard to prevent stale Docker cache artifacts from surviving a hermit upgrade. Do not rely on hermit-docker up to trigger a rebuild.
Render docker-compose.security.yml from state-templates/docker/security/docker-compose.security.yml.template by substituting the placeholder blocks. The template has placeholders:
{{NETWORKS_BLOCK}} — empty unless Prompt 1 enabled. When enabled, substitute chosen_subnet and chosen_gateway from the subnet auto-detection step above:
networks:
hermit-net:
driver: bridge
ipam:
config:
- subnet: <chosen_subnet>
gateway: <chosen_gateway>
{{HERMIT_NETGUARD_SERVICE}} — empty unless Prompt 1 enabled. When enabled, substitute chosen_netguard_ip and dns_log_only. Also render {{NETGUARD_PORTS_BLOCK}} (see below):
hermit-netguard:
build:
context: ./.claude-code-hermit/docker
dockerfile: Dockerfile.hermit-netguard
# NET_BIND_SERVICE: dnsmasq retains it post-bind-drop. SETUID+SETGID:
# drops to UID/GID 100 (Alpine's `dnsmasq` user).
cap_add: [NET_ADMIN, NET_BIND_SERVICE, SETUID, SETGID]
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 256
networks:
hermit-net:
ipv4_address: <chosen_netguard_ip>
volumes:
- ./.claude-code-hermit/docker/nftables.conf:/etc/nftables.conf:ro
- ./.claude-code-hermit/docker/dnsmasq.allowlist:/etc/dnsmasq.allowlist:ro
environment:
- DNS_LOG_ONLY=<dns_log_only>
restart: unless-stopped
{{NETGUARD_SYSCTLS}}
{{NETGUARD_PORTS_BLOCK}}
healthcheck:
test: ["CMD-SHELL", "nft list ruleset | grep -q 'table inet firewall' && (! [ -f /etc/dnsmasq.allowlist ] || pgrep dnsmasq)"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
Where <dns_log_only> = "1" if dns_mode == "log-only" else "0". {{NETGUARD_SYSCTLS}} is the sysctls block from below if Prompt 2 enabled AND Prompt 1 enabled (sysctls live on netguard, the netns owner).
{{NETGUARD_PORTS_BLOCK}} — empty unless persisted_publish_ports is non-empty AND Prompt 1 is enabled. When non-empty, render as long-form YAML using the parsed port objects (target, published, host_ip, protocol, mode). Omit host_ip if "0.0.0.0" (Compose default). Omit mode if "ingress" (Compose default for non-swarm). Example for two ports:
ports:
- target: 3000
published: "3000"
protocol: tcp
- target: 8080
published: "8080"
protocol: tcp
If the ports were detected via grep fallback (detected_hermit_ports_unparsed=true) and no parsed shape is available, leave {{NETGUARD_PORTS_BLOCK}} empty and tell the operator: "Could not parse port shapes from the compose config — manually add your ports: block to hermit-netguard in the rendered overlay and delete it from the base file."
{{HERMIT_NETWORK_MODE}} — empty unless Prompt 1 enabled. When enabled:
network_mode: "service:hermit-netguard"
depends_on:
hermit-netguard:
condition: service_healthy
{{HERMIT_RESOURCE_BOUNDS}} — empty unless Prompt 2 enabled. When enabled:
mem_limit: 4g
memswap_limit: 4g
cpus: 2.0
(Custom values substituted if operator provided them.)
{{HERMIT_SYSCTLS_ON_HERMIT}} — sysctls block from below ONLY if Prompt 2 enabled, sysctls_enabled is true, AND Prompt 1 is OFF. (When Prompt 1 is on, sysctls go on netguard via {{NETGUARD_SYSCTLS}}.)
{{HERMIT_AUDIT_ENV}} — empty unless Prompt 3 enabled. When enabled:
environment:
- HERMIT_PLUGIN_INSTALL_AUDIT=1
The shared sysctls block (used in either {{NETGUARD_SYSCTLS}} or {{HERMIT_SYSCTLS_ON_HERMIT}}):
sysctls:
- net.ipv4.conf.all.accept_redirects=0
- net.ipv4.conf.all.send_redirects=0
- net.ipv4.conf.all.accept_source_route=0
- net.ipv4.conf.default.accept_redirects=0
Copy from state-templates/docker/security/ into .claude-code-hermit/docker/:
Dockerfile.hermit-netguard.template → Dockerfile.hermit-netguardnetguard-entrypoint.sh.template → netguard-entrypoint.sh (chmod +x)nftables.conf.template → nftables.conf, substituting:
{{LAN_ALLOWLIST_RULES}} → for each entry in lan_allowlist, render ip daddr <entry> accept (8-space indent matching the chain body). Empty string if no entries.{{DNSMASQ_UID}} → the literal string 100 (Alpine dnsmasq's static UID — see step 6a).dnsmasq.allowlist.template → dnsmasq.allowlist, substituting:
{{FLEET_DOMAINS}} → for each fleet domain, render server=/<domain>/1.1.1.1 with a comment line above grouping by source plugin. Empty string if no entries.{{ADDITIONAL_DOMAINS}} → for each operator-added domain, render server=/<domain>/1.1.1.1. Empty string if no entries.After all files are copied, force a fresh netguard image build:
timeout 180s docker compose -f docker-compose.hermit.yml -f docker-compose.security.yml build --no-cache hermit-netguard
If the build exits non-zero, run docker compose -f docker-compose.hermit.yml -f docker-compose.security.yml logs hermit-netguard --tail=30, surface the output, and stop. Do not proceed to step 6d.
Print a one-screen summary of files written (paths + line counts). Ask AskUserQuestion (header: "Apply") — "Yes — restart container now" / "No — I'll restart manually later".
On "No — I'll restart manually later": skip to step 9 with the note "Overlay written. Run hermit-docker down then hermit-docker up when ready to apply. Re-run /docker-security at any time to verify."
Hard gate — re-check base ports before starting:
Before running hermit-docker up, re-run the step 5 port detection: timeout 10s docker compose -f docker-compose.hermit.yml config --format json 2>/dev/null | python3 -c "import json,sys; p=json.load(sys.stdin)['services'].get('hermit',{}).get('ports',[]); print(len(p))". If the result is non-zero (or the command output from the grep fallback is non-empty) AND docker.security.network.enabled === true (LAN containment is on): stop here and print:
Cannot start container — docker-compose.hermit.yml still publishes ports on hermit.
Those ports are now published by hermit-netguard via the overlay.
Delete the `ports:` block from docker-compose.hermit.yml, then run:
.claude-code-hermit/bin/hermit-docker up
Skip steps 8.1-8.4 entirely. Tell the operator: "Overlay and config have been written — just delete the base ports: block first."
If operator chose to restart now AND container was running before this skill (and the hard gate passed):
.claude-code-hermit/bin/hermit-docker down..claude-code-hermit/bin/hermit-docker up (the wrapper now picks up the overlay automatically). The first up will trigger docker compose build hermit-netguard if Prompt 1 was enabled — wait for completion (can be 30-60s on first build).docker compose -f docker-compose.hermit.yml -f docker-compose.security.yml ps --status running --format '{{.Service}}' every 2s for up to 30s; expect hermit (and hermit-netguard if Prompt 1 enabled). If either is missing after 30s: run docker compose ... logs --tail=30 for the missing service, surface output, stop.Run the verification block via docker exec hermit sh -c '...'. The hermit base image has python3, jq, curl, and glibc (getent) — no nc or nslookup. Use Python and getent.
docker compose -f docker-compose.hermit.yml -f docker-compose.security.yml \
exec -T hermit sh -s <<'VERIFY_EOF'
set +e
echo "=== Baseline (v1.0.26 — should always be on) ==="
grep -E '^Cap(Eff|Bnd)' /proc/self/status
grep NoNewPrivs /proc/self/status
echo "pids.max: $(cat /sys/fs/cgroup/pids.max)"
echo
echo "=== LAN containment ==="
python3 -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('192.168.1.1',22))" 2>&1 \
| grep -qE 'timed out|refused|Network is unreachable' \
&& echo " LAN-block: OK (192.168.1.1:22 unreachable)" \
|| echo " LAN-block: NOT BLOCKED (compromised hermit could reach LAN)"
echo
echo "=== DNS policy ==="
getent hosts api.anthropic.com >/dev/null \
&& echo " DNS-allow: OK (api.anthropic.com resolves)" \
|| echo " DNS-allow: FAIL (allowlisted domain does not resolve)"
_dns_err=$(mktemp)
trap 'rm -f "$_dns_err"' EXIT
timeout 2s python3 -c "import socket; socket.gethostbyname('example.com')" >/dev/null 2>"$_dns_err"
dns_rc=$?
if [ $dns_rc -eq 124 ]; then
echo " DNS-block: FAIL — query timed out (likely DNS leak; no-resolv missing or upstream unreachable)"
elif grep -qE 'Name or service not known|nodename nor servname' "$_dns_err"; then
echo " DNS-block: OK (example.com NXDOMAIN — policy applies)"
else
echo " DNS-block: FAIL — example.com resolved or unexpected error"
fi
python3 -c "
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(2)
# Hand-crafted DNS query for example.com type A. Using bytes.fromhex avoids
# escape-processing pitfalls when this SKILL travels through model -> shell -> python.
# Layout: header(12B: id=1234 flags=0100 qdcount=1 the rest 0) + qname(7example3com0) + qtype 0001 + qclass 0001
q = bytes.fromhex('123401000001000000000000076578616d706c6503636f6d0000010001')
sock.sendto(q, ('8.8.8.8', 53))
try:
resp, _ = sock.recvfrom(512)
rcode = resp[3] & 0x0f
print('NXDOMAIN' if rcode == 3 else f'rcode={rcode}')
except Exception as e:
print(f'no-response ({e})')
" | grep -q NXDOMAIN \
&& echo " DNS-redirect: OK (port-53 redirected even with explicit upstream)" \
|| echo " DNS-redirect: NOT ENFORCED (or in log-only mode — expected)"
echo
echo "=== Resource bounds + sysctls ==="
echo " memory.max: $(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo 'unset')"
[ -r /proc/sys/net/ipv4/conf/all/accept_redirects ] \
&& [ "$(cat /proc/sys/net/ipv4/conf/all/accept_redirects)" = "0" ] \
&& echo " sysctls: OK (ICMP redirects disabled)" \
|| echo " sysctls: not active (host mode, or Prompt 2 off)"
echo
echo "=== Audit log ==="
test -f "${AGENT_DIR:-/home/claude/project/.claude-code-hermit}/state/plugin-installs.jsonl" \
&& echo " Audit log: OK ($(wc -l < ${AGENT_DIR:-/home/claude/project/.claude-code-hermit}/state/plugin-installs.jsonl) entries)" \
|| echo " Audit log: not yet written (no plugin installs since last boot)"
VERIFY_EOF
Surface the output to the operator. Suggest: "Run /claude-code-hermit:hermit-doctor to also verify the docker-security check shows green."
Print a brief summary:
docker-security applied — current posture:
LAN containment: on (DNS log-only) | LAN carve-outs: 192.168.1.50
Resource bounds: on (mem 4g, cpus 2.0)
Audit log: on
Reverse: re-run /docker-security and answer No to every prompt, OR
delete docker-compose.security.yml and re-run hermit-docker up.
Tune DNS allowlist: edit .claude-code-hermit/docker/dnsmasq.allowlist,
then `hermit-docker down && hermit-docker up`
(restart hermit-netguard alone leaves hermit with stale resolver state).
Reversal, limitations, DNS allowlist tuning, and design rationale: see docs/docker-security.md.
development
Use when the user asks about cost structure or composition: which token types cost the most, how cache reads compare to output costs, which sessions are draining budget, or what cold starts add up to. This is the 'why is my bill high' skill. It audits where spend goes across token categories (cache_read, cache_write, output, input) and per-session attribution. Invoke for questions like 'cost breakdown', 'which sessions are expensive', 'what's driving spend', or 'cold start costs'. Not for week-over-week trend lines (use hermit-evolution) or simple total-spend summaries.
tools
Presence history & tracker-health report — current home/away state, reliability, recent arrival/departure transitions, and activity patterns for person/device_tracker entities. Use when the operator asks about presence history or when a presence-dependent automation (locks, alarm, vacuum, climate) misbehaves.
development
Evening house brief — end-of-day security check, device status, and energy snapshot. Runs as a daily routine at 22:30 or on demand.
tools
Browse and explain the hermit's Home Assistant automations — list by topic, filter by keyword with plain-language YAML explanations, or sort by last-fired. Read-only. Use when the operator asks "what automations do I have / what does this one do / which haven't fired."