
Audit Openclaw Security
- 82 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with security tasks during AI-assisted development.
About
audit-openclaw-security is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
- audit-openclaw-security
- Security
- AI-coding skill
Audit Openclaw Security by the numbers
- 82 all-time installs (skills.sh)
- Ranked #1,086 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill audit-openclaw-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with security tasks during AI-assisted development.
Files
audit-openclaw-security
Run a defensive, permissioned security audit of an OpenClaw deployment and turn the results into a practical remediation plan.
This revision is tuned for OpenClaw 2026.3.8 and uses {baseDir} when referencing bundled scripts from commands.
Guardrails
1. Only audit systems the user owns or has explicit permission to assess. 2. Never ask for raw secrets. Do not request gateway tokens/passwords, model API keys, session cookies, OAuth creds, or raw credential files. 3. Prefer outputs that are designed to be shareable or redacted:
openclaw status --allopenclaw status --deepopenclaw gateway probe --jsonopenclaw security audit --jsonopenclaw security audit --deep --json
4. Treat the Gateway, Control UI, browser control, paired nodes, and automation surfaces as operator-level access. 5. Default to audit-only. Before any config edits, --fix operations, firewall changes, or restarts, create a backup first and get explicit user approval. 6. When the user wants remediation, make the backup step explicit:
openclaw backup create --verify- use
--no-include-workspaceif the config is invalid but you still need state + creds - use
--only-configif the user only wants a minimal safety copy before edits
What “good” looks like
- Gateway is bound to loopback unless there is a deliberate, defended reason not to.
- Strong Gateway auth is enabled.
- No accidental public exposure (LAN bind, port-forward, permissive reverse proxy, Tailscale Funnel).
- Control UI is either localhost/Serve or explicitly origin-restricted behind a trusted proxy.
- DMs require pairing or strict allowlists.
- Groups require mention gating and are not open if broad tools are enabled.
session.dmScopeis isolated appropriately:per-channel-peerfor most multi-user setupsper-account-channel-peerwhen the same provider runs multiple accounts- Tooling is least privilege:
tools.profile: "messaging"or stricter for inbox-facing agents- deny
group:runtime,group:fs,group:automationon untrusted surfaces tools.fs.workspaceOnly: truetools.exec.security: "deny"or at least approval-gatedtools.elevated.enabled: falseunless there is a narrow, intentional need- Plugins and skills are explicitly trusted, minimally writable, and not used as an easy persistence path.
- Secrets, transcripts, and logs have tight permissions and an intentional retention plan.
Use the bundled files progressively
Only open the extra files you need for the task:
references/command-cheatsheet.md— exact command laddersreferences/openclaw-audit-checks.md— current high-signalcheckIdglossaryreferences/openclaw-baseline-config.md— secure baseline snippetsreferences/platform-mac-mini.mdreferences/platform-personal-laptop.mdreferences/platform-docker.mdreferences/platform-aws-ec2.mdassets/report-template.md— report structure
Step 0 — Establish context quickly
Collect just enough context to choose the audit path:
- Where is OpenClaw running?
- macOS host / Mac mini
- personal laptop
- Docker host
- EC2 / VPS / other cloud VM
- Install style?
- native install
- Docker / Compose
- source checkout
- Do we have local shell access?
- Mode A: chat-only / user runs commands
- Mode B: agent can run shell commands directly
Mode A — Assisted self-audit (chat-only)
Ask the user to run the following on the OpenClaw host and share the outputs.
Minimum audit set
openclaw --version
openclaw status --all
openclaw status --deep
openclaw gateway status
openclaw gateway probe --json
openclaw channels status --probe
openclaw doctor
openclaw security audit --json
openclaw security audit --deep --jsonHelpful extras
openclaw health --json
openclaw backup create --dry-run --json
openclaw backup create --only-config --dry-run --json
openclaw skills list --eligible --json
openclaw plugins list --jsonSafe targeted config reads
Prefer targeted reads over a full config dump:
openclaw config get gateway.bind
openclaw config get gateway.auth.mode
openclaw config get gateway.auth.allowTailscale
openclaw config get gateway.controlUi.allowedOrigins
openclaw config get gateway.trustedProxies
openclaw config get gateway.allowRealIpFallback
openclaw config get discovery.mdns.mode
openclaw config get session.dmScope
openclaw config get tools.profile
openclaw config get tools.fs.workspaceOnly
openclaw config get tools.exec.security
openclaw config get tools.elevated.enabled
openclaw config get channels.defaults.dmPolicy
openclaw config get channels.defaults.groupPolicy
openclaw config get logging.redactSensitiveDM / group follow-up checks
If the issue is “the bot is online but DMs or groups behave strangely”, check pairing and mention gating:
openclaw pairing list <channel>Examples of <channel> include discord, slack, signal, telegram, whatsapp, matrix, imessage, and bluebubbles.
If the user must share the config
OpenClaw config is often JSON5-like. Redact it before sharing:
python3 "{baseDir}/scripts/redact_openclaw_config.py" ~/.openclaw/openclaw.json > openclaw.json.redactedHost / network snapshots
macOS
whoami
sw_vers
uname -a
lsof -nP -iTCP -sTCP:LISTEN
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
/usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode
fdesetup status || trueLinux / cloud VM
whoami
cat /etc/os-release
uname -a
ss -ltnp
sudo ufw status verbose || true
sudo nft list ruleset || true
sudo iptables -S || trueDocker / Compose
docker ps --format 'table {{.Names}} {{.Image}} {{.Ports}}'
docker compose ps || true
docker port openclaw-gateway 18789 || trueMode B — Automated local audit (shell access)
Run the bundled collector and report renderer:
bash "{baseDir}/scripts/collect_openclaw_audit.sh" --out ./openclaw-audit
python3 "{baseDir}/scripts/render_report.py" --input ./openclaw-audit --output ./openclaw-security-report.mdThen review openclaw-security-report.md, refine wording where needed, and present the final report to the user.
Notes on the collector
- It is read-only by default.
- It does not run
openclaw security audit --fix. - It collects shareable CLI diagnostics plus basic host/network context.
- It now captures current high-value signals such as:
openclaw status --deepopenclaw gateway probe --jsonopenclaw channels status --probe- targeted safe
config getvalues - backup dry-run metadata
How to interpret the audit
Use OpenClaw’s own security audit output as the primary source of truth, then translate it into a clear threat narrative.
Triage order
Prioritise in this order:
1. Anything open + tools enabled Lock down DMs/groups first, then tighten tool policy and sandboxing. 2. Public network exposure LAN bind, Funnel, missing auth, weak reverse-proxy handling. 3. Browser / node / Control UI exposure Treat these as operator access, not “just another feature”. 4. Filesystem permissions State dir, config file, auth profiles, logs, and transcript locations. 5. Plugin / skill supply chain Trust only what is intentionally installed and writable by the right user. 6. Model and prompt-injection resilience Important, but not a substitute for access control.
Findings that are easy to miss in newer OpenClaw builds
Pay extra attention to these newer or high-signal check IDs:
gateway.control_ui.allowed_origins_requiredgateway.control_ui.host_header_origin_fallbackgateway.real_ip_fallback_enabledconfig.insecure_or_dangerous_flagssandbox.dangerous_network_modetools.exec.host_sandbox_no_sandbox_defaultstools.exec.host_sandbox_no_sandbox_agentstools.exec.safe_bins_interpreter_unprofiledskills.workspace.symlink_escapesecurity.exposure.open_groups_with_elevatedsecurity.exposure.open_groups_with_runtime_or_fssecurity.trust_model.multi_user_heuristic
Use references/openclaw-audit-checks.md and assets/openclaw_checkid_map.json to map each finding to likely config paths and remediation areas.
Core remediation patterns
1) Gateway exposure and auth
- Prefer
gateway.bind: "loopback". - Require token or password auth for anything beyond strictly local use.
- Do not treat
gateway.remote.*values as protection for local WS access; actual protection comes fromgateway.auth.*. - If the user needs a new shared secret,
openclaw doctor --generate-gateway-tokenis the safe boring path.
2) Reverse proxies and browser-origin policy
If there is a reverse proxy in front of the Gateway:
- configure
gateway.trustedProxies - keep
gateway.allowRealIpFallback: falseunless there is a very specific need - for non-loopback Control UI use, set
gateway.controlUi.allowedOrigins - do not enable Host-header origin fallback unless the user knowingly accepts the downgrade
3) Tailscale Serve vs Funnel
tailscale.mode: "serve"keeps the Gateway tailnet-only.tailscale.mode: "funnel"is public and should be treated as urgent/high risk.gateway.auth.allowTailscalecan allow tokenless Control UI/WebSocket auth via Tailscale identity headers. That assumes the gateway host itself is trusted.- If untrusted code can run on the host, or if any reverse proxy sits in front of the gateway, disable
gateway.auth.allowTailscaleand require token/password or trusted-proxy auth.
4) DM and group isolation
- Use
dmPolicy: "pairing"orallowlistfor inbox-facing bots. - For shared or support-style inboxes, set
session.dmScope: "per-channel-peer". - For multi-account channel setups, prefer
per-account-channel-peer. - Avoid
groupPolicy: "open"unless the tool surface is extremely limited. - Require mentions in groups and use
agents.list[].groupChat.mentionPatternswhere native mentions are unreliable.
5) Tool surface reduction
Start from the conservative baseline in references/openclaw-baseline-config.md.
Good defaults for user-facing agents:
tools.profile: "messaging"- deny
group:automation - deny
group:runtime - deny
group:fs tools.fs.workspaceOnly: truetools.exec.security: "deny"andask: "always"tools.exec.applyPatch.workspaceOnly: truetools.elevated.enabled: false
6) Node / browser / automation trust
- Paired nodes are remote execution surfaces. Audit them like you would audit operator access.
- Browser control is not “just viewing pages”; it is effectively remote operator capability.
gateway/crontools create persistence and should not be reachable from untrusted chat surfaces.
7) Secrets, logs, transcripts, and writable paths
Audit and discuss these paths carefully without asking for raw contents:
~/.openclaw/openclaw.json~/.openclaw/secrets.json~/.openclaw/agents/<agentId>/agent/auth-profiles.json~/.openclaw/agents/<agentId>/sessions/*.jsonl/tmp/openclaw/openclaw-YYYY-MM-DD.log- pairing stores under
~/.openclaw/credentials/
Platform-specific guidance
Load the matching playbook when the environment is clear:
- macOS host / Mac mini ->
references/platform-mac-mini.md - personal laptop ->
references/platform-personal-laptop.md - Docker / Compose ->
references/platform-docker.md - EC2 / VPS ->
references/platform-aws-ec2.md
Deliverable format
Use assets/report-template.md or the rendered report from {baseDir}/scripts/render_report.py.
The final deliverable should include:
- executive summary
- environment overview
- findings table with redacted evidence
- sequenced remediation plan
- verification commands
- residual risk / operational practices
Troubleshooting notes
“openclaw: command not found”
- Confirm the CLI is installed and on
PATH. - On Windows, prefer WSL2 for shell-driven audit flows.
- Re-run the official install / update path, then retry
openclaw --version.
“Gateway won’t start — configuration invalid”
OpenClaw now fails closed on invalid config keys, invalid values, or invalid types. That is intentional and security-relevant.
Use:
openclaw doctor
openclaw doctor --fixEven when the config is invalid, diagnostic commands such as openclaw status, openclaw gateway status, openclaw gateway probe, and openclaw health are still useful.
“Runtime: running” but “RPC probe: failed”
Trust the probe details, not just the supervisor status:
Probe targetListeningLast gateway error
This often means service/config drift, auth mismatch, or a listener that is not actually reachable by the CLI.
“Bot is online but DMs fail”
Check:
openclaw channels status --probe
openclaw pairing list <channel>Common root causes:
- pending pairing approval
dmPolicytoo strict for the expected sender- provider-side permission or token drift
“Groups are silent”
Check:
groupPolicyrequireMentionmentionPatterns- audit findings about open groups combined with runtime/fs/elevated tools
Trigger tests (skill author sanity check)
Should trigger:
- “Can you audit my OpenClaw setup for security?”
- “My OpenClaw gateway is exposed through Tailscale Serve — is that okay?”
- “Interpret my
openclaw security audit --deep --jsonfindings.” - “I’m running OpenClaw in Docker on a VPS; help me harden it.”
- “Why is my OpenClaw Control UI complaining about origins and trusted proxies?”
- “My bot is online but DMs don’t reply; can you audit pairing and access policy?”
Should not trigger:
- generic macOS hardening unrelated to OpenClaw
- generic Docker security unrelated to OpenClaw
- general AWS or VPS hardening unrelated to OpenClaw
- unrelated software audits
{
"fs.state_dir.perms_world_writable": {
"severity": "critical",
"category": "filesystem",
"why": "Other users or processes can modify the full OpenClaw state directory.",
"primary_fix": "filesystem perms on ~/.openclaw",
"auto_fix": true
},
"fs.config.perms_writable": {
"severity": "critical",
"category": "filesystem",
"why": "Other users can change auth, tool policy, or routing config.",
"primary_fix": "filesystem perms on ~/.openclaw/openclaw.json",
"auto_fix": true
},
"fs.config.perms_world_readable": {
"severity": "critical",
"category": "filesystem",
"why": "The config may leak tokens or other sensitive settings.",
"primary_fix": "filesystem perms on the active config file",
"auto_fix": true
},
"gateway.bind_no_auth": {
"severity": "critical",
"category": "network",
"why": "The Gateway is remotely reachable without shared-secret auth.",
"primary_fix": "gateway.bind, gateway.auth.*",
"auto_fix": false
},
"gateway.loopback_no_auth": {
"severity": "critical",
"category": "network",
"why": "A reverse-proxied loopback listener may become effectively unauthenticated.",
"primary_fix": "gateway.auth.*, reverse-proxy setup",
"auto_fix": false
},
"gateway.http.no_auth": {
"severity": "warn/critical",
"category": "network",
"why": "HTTP API endpoints are reachable while `auth.mode=\"none\"`.",
"primary_fix": "gateway.auth.mode, gateway.http.endpoints.*",
"auto_fix": false
},
"gateway.tools_invoke_http.dangerous_allow": {
"severity": "warn/critical",
"category": "tools",
"why": "Dangerous tools are reachable through HTTP API paths.",
"primary_fix": "gateway.tools.allow",
"auto_fix": false
},
"gateway.nodes.allow_commands_dangerous": {
"severity": "warn/critical",
"category": "nodes",
"why": "High-impact node commands are reachable remotely.",
"primary_fix": "gateway.nodes.allowCommands",
"auto_fix": false
},
"gateway.tailscale_funnel": {
"severity": "critical",
"category": "network",
"why": "Tailscale Funnel makes the Gateway publicly reachable.",
"primary_fix": "gateway.tailscale.mode",
"auto_fix": false
},
"gateway.control_ui.allowed_origins_required": {
"severity": "critical",
"category": "control-ui",
"why": "A non-loopback Control UI is missing an explicit browser-origin allowlist.",
"primary_fix": "gateway.controlUi.allowedOrigins",
"auto_fix": false
},
"gateway.control_ui.host_header_origin_fallback": {
"severity": "warn/critical",
"category": "control-ui",
"why": "Host-header origin fallback weakens DNS rebinding protections.",
"primary_fix": "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"auto_fix": false
},
"gateway.control_ui.insecure_auth": {
"severity": "warn",
"category": "control-ui",
"why": "Control UI insecure-auth compatibility mode is enabled.",
"primary_fix": "gateway.controlUi.allowInsecureAuth",
"auto_fix": false
},
"gateway.control_ui.device_auth_disabled": {
"severity": "critical",
"category": "control-ui",
"why": "Device-identity checks are disabled entirely.",
"primary_fix": "gateway.controlUi.dangerouslyDisableDeviceAuth",
"auto_fix": false
},
"gateway.real_ip_fallback_enabled": {
"severity": "warn/critical",
"category": "reverse-proxy",
"why": "Trusting X-Real-IP can enable source-IP spoofing via proxy misconfiguration.",
"primary_fix": "gateway.allowRealIpFallback, gateway.trustedProxies",
"auto_fix": false
},
"discovery.mdns_full_mode": {
"severity": "warn/critical",
"category": "discovery",
"why": "mDNS full mode advertises extra metadata on the local network.",
"primary_fix": "discovery.mdns.mode, gateway.bind",
"auto_fix": false
},
"config.insecure_or_dangerous_flags": {
"severity": "warn",
"category": "config",
"why": "One or more insecure or dangerous flags are enabled.",
"primary_fix": "see finding detail",
"auto_fix": false
},
"hooks.token_too_short": {
"severity": "warn",
"category": "hooks",
"why": "The hook ingress token is easier to brute-force.",
"primary_fix": "hooks.token",
"auto_fix": false
},
"hooks.request_session_key_enabled": {
"severity": "warn/critical",
"category": "hooks",
"why": "External callers can choose session keys, creating persistence or collision risk.",
"primary_fix": "hooks.allowRequestSessionKey",
"auto_fix": false
},
"hooks.request_session_key_prefixes_missing": {
"severity": "warn/critical",
"category": "hooks",
"why": "There is no bound on externally supplied session-key shapes.",
"primary_fix": "hooks.allowedSessionKeyPrefixes",
"auto_fix": false
},
"logging.redact_off": {
"severity": "warn",
"category": "logging",
"why": "Sensitive values can leak into logs and status output.",
"primary_fix": "logging.redactSensitive",
"auto_fix": true
},
"sandbox.docker_config_mode_off": {
"severity": "warn",
"category": "sandbox",
"why": "Docker sandbox config is present but sandboxing is inactive.",
"primary_fix": "agents.*.sandbox.mode",
"auto_fix": false
},
"sandbox.dangerous_network_mode": {
"severity": "critical",
"category": "sandbox",
"why": "The Docker sandbox uses host or namespace-join networking.",
"primary_fix": "agents.*.sandbox.docker.network",
"auto_fix": false
},
"tools.exec.host_sandbox_no_sandbox_defaults": {
"severity": "warn",
"category": "tools",
"why": "Default `exec host=sandbox` resolves to host exec when sandboxing is off.",
"primary_fix": "tools.exec.host, agents.defaults.sandbox.mode",
"auto_fix": false
},
"tools.exec.host_sandbox_no_sandbox_agents": {
"severity": "warn",
"category": "tools",
"why": "Per-agent `exec host=sandbox` resolves to host exec when sandboxing is off.",
"primary_fix": "agents.list[].tools.exec.host, agents.list[].sandbox.mode",
"auto_fix": false
},
"tools.exec.safe_bins_interpreter_unprofiled": {
"severity": "warn",
"category": "tools",
"why": "Interpreter/runtime bins are allowed without explicit safe profiles.",
"primary_fix": "tools.exec.safeBins, tools.exec.safeBinProfiles, agents.list[].tools.exec.*",
"auto_fix": false
},
"skills.workspace.symlink_escape": {
"severity": "warn",
"category": "skills",
"why": "A skill path in the workspace resolves outside the workspace root.",
"primary_fix": "workspace skills/** filesystem state",
"auto_fix": false
},
"security.exposure.open_groups_with_elevated": {
"severity": "critical",
"category": "exposure",
"why": "Open groups plus elevated tools create a high-impact prompt-injection path.",
"primary_fix": "channels.*.groupPolicy, tools.elevated.*",
"auto_fix": false
},
"security.exposure.open_groups_with_runtime_or_fs": {
"severity": "critical/warn",
"category": "exposure",
"why": "Open groups can reach command or file tools without sandbox/workspace guards.",
"primary_fix": "channels.*.groupPolicy, tools.profile/deny, tools.fs.workspaceOnly, agents.*.sandbox.mode",
"auto_fix": false
},
"security.trust_model.multi_user_heuristic": {
"severity": "warn",
"category": "trust-model",
"why": "The config looks multi-user without matching isolation and tool hardening.",
"primary_fix": "sandbox.mode, tool deny, workspace scoping",
"auto_fix": false
},
"tools.profile_minimal_overridden": {
"severity": "warn",
"category": "tools",
"why": "Per-agent overrides bypass a minimal global profile.",
"primary_fix": "agents.list[].tools.profile",
"auto_fix": false
},
"plugins.tools_reachable_permissive_policy": {
"severity": "warn",
"category": "plugins",
"why": "Extension tools are reachable in permissive contexts.",
"primary_fix": "tools.profile plus tool allow/deny",
"auto_fix": false
},
"models.small_params": {
"severity": "critical/info",
"category": "model",
"why": "Small or weak models plus tool access increase prompt-injection risk.",
"primary_fix": "model choice plus sandbox/tool policy",
"auto_fix": false
}
}
OpenClaw Security Audit Report
Executive summary
- Overall risk rating: (Critical / High / Moderate / Low / Informational)
- OpenClaw version: (
openclaw --version) - Most urgent issues: (1-3 bullets)
- Big picture: What is exposed, who can talk to the bot, what the bot can do.
Environment overview
- Host type: (macOS host / laptop / Docker / EC2 / VPS / other)
- OS + version:
- Gateway bind + access method: (loopback / tailnet / reverse proxy / LAN / public)
- Gateway auth mode:
- Control UI origin posture:
- Trusted proxies / real-IP policy:
- Session dmScope:
- DM policy / group policy:
- Tool profile and notable denies/allows:
- Key sensitive paths: (
~/.openclaw, auth profiles, sessions, logs)
Findings
| Severity | Check ID / finding | Evidence (redacted) | Why it matters | Recommended fix | Verify |
|---|---|---|---|---|---|
| Critical | |||||
| High | |||||
| Medium | |||||
| Low |
Remediation plan
Phase 1 — Stop the bleeding (same day)
1. 2. 3.
Phase 2 — Reduce blast radius (this week)
1. 2. 3.
Phase 3 — Operationalise (ongoing)
- update cadence (OS + OpenClaw)
- token/password rotation policy
- backup/restore routine
- transcript/log retention and pruning
Verification checklist
- [ ]
openclaw security audit --deep --jsonshows no critical findings - [ ]
openclaw gateway probe --jsonmatches the intended listener/auth path - [ ]
openclaw channels status --probeshows the expected ready/connected channels - [ ] Gateway is not reachable from untrusted networks
- [ ] DM pairing/allowlists are in place
- [ ] Group mention gating is enabled where required
- [ ] File permissions are tightened for OpenClaw state and config
- [ ] Tools are limited to what is actually required
Residual risk notes
Even a well-hardened agent that can read messages and call tools still carries prompt-injection and social-engineering risk. Record which surfaces remain intentionally open, which tools remain enabled, and how recovery works if the Gateway host or credentials are compromised.
audit-openclaw-security v2
This bundle updates the original skill for current OpenClaw and current Agent Skills / OpenClaw skill-loader expectations.
Main changes
- migrated
SKILL.mdfrontmatter to an OpenClaw-compatible form: - single-line frontmatter keys
- inline JSON
metadata {baseDir}references for bundled scripts- retuned the description so it is specific about both:
- what the skill does
- when it should trigger
- updated the audit workflow for current OpenClaw commands:
openclaw status --deepopenclaw gateway probe --jsonopenclaw channels status --probeopenclaw backup create --verify- expanded the current audit-check glossary and check-id map
- added current reverse-proxy, Control UI origin, and Tailscale Serve/Funnel guidance
- added current
session.dmScopeguidance for shared inbox and multi-account setups - refreshed platform playbooks
- upgraded bundled scripts for agentic use:
--helpsupport- safer config redaction flow
- richer rendered report with environment clues and merged audit findings
Bundle versioning
- Canonical skill name remains
audit-openclaw-securityso the directory still matches thenamefield. - Bundle metadata version is now
2.1.0. - Validated against OpenClaw
2026.3.8.
Command cheat sheet (audit focus, OpenClaw 2026.3.8)
Run these on the OpenClaw host.
Fast command ladder
openclaw --version
openclaw status --all
openclaw status --deep
openclaw gateway status
openclaw gateway probe --json
openclaw channels status --probe
openclaw doctor
openclaw security audit --json
openclaw security audit --deep --jsonBefore remediation
Back up first:
openclaw backup create --verifyIf the config is invalid but you still want a safety copy:
openclaw backup create --no-include-workspace
openclaw backup create --only-configRead-only dry runs:
openclaw backup create --dry-run --json
openclaw backup create --only-config --dry-run --jsonHelpful context
openclaw health --json
openclaw skills list --eligible --json
openclaw plugins list --jsonSafe targeted config reads
These are usually safe to share:
openclaw config get gateway.bind
openclaw config get gateway.auth.mode
openclaw config get gateway.auth.allowTailscale
openclaw config get gateway.controlUi.allowedOrigins
openclaw config get gateway.trustedProxies
openclaw config get gateway.allowRealIpFallback
openclaw config get discovery.mdns.mode
openclaw config get session.dmScope
openclaw config get tools.profile
openclaw config get tools.fs.workspaceOnly
openclaw config get tools.exec.security
openclaw config get tools.elevated.enabled
openclaw config get channels.defaults.dmPolicy
openclaw config get channels.defaults.groupPolicy
openclaw config get logging.redactSensitiveDM / group access checks
openclaw pairing list <channel>Common examples: discord, slack, signal, telegram, whatsapp, matrix, imessage, bluebubbles.
Safe sharing
Prefer openclaw status --all, openclaw status --deep, and openclaw security audit --json.
If the user must share the config, redact it first:
python3 "{baseDir}/scripts/redact_openclaw_config.py" ~/.openclaw/openclaw.json > openclaw.json.redactedHost / network checks
macOS:
lsof -nP -iTCP -sTCP:LISTEN
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
/usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode
fdesetup status || trueLinux:
ss -ltnp
sudo ufw status verbose || true
sudo nft list ruleset || true
sudo iptables -S || trueDocker / Compose:
docker ps --format 'table {{.Names}} {{.Image}} {{.Ports}}'
docker compose ps || true
docker port openclaw-gateway 18789 || trueAfter remediation
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probe
openclaw doctorOpenClaw security audit: high-signal checks (quick glossary)
This is a convenience map for interpreting openclaw security audit --json and --deep --json.
Treat OpenClaw’s own audit output as the source of truth. This file is intentionally not exhaustive.
| checkId | Typical severity | Why it matters | Primary fix key/path | Auto-fix? |
|---|---|---|---|---|
fs.state_dir.perms_world_writable | Critical | Other users/processes can modify the full OpenClaw state directory. | filesystem perms on ~/.openclaw | Yes |
fs.config.perms_writable | Critical | Others can change auth, tool policy, and routing config. | perms on ~/.openclaw/openclaw.json | Yes |
fs.config.perms_world_readable | Critical | The config can leak tokens or security-sensitive settings. | perms on config file | Yes |
gateway.bind_no_auth | Critical | Remote bind without shared secret. | gateway.bind, gateway.auth.* | No |
gateway.loopback_no_auth | Critical | Reverse-proxied loopback can become unauthenticated. | gateway.auth.*, proxy setup | No |
gateway.http.no_auth | Warn/Critical | HTTP endpoints are reachable with auth.mode="none". | gateway.auth.mode, gateway.http.endpoints.* | No |
gateway.tools_invoke_http.dangerous_allow | Warn/Critical | Dangerous tools are reachable through HTTP API paths. | gateway.tools.allow | No |
gateway.nodes.allow_commands_dangerous | Warn/Critical | High-impact node commands are reachable remotely. | gateway.nodes.allowCommands | No |
gateway.tailscale_funnel | Critical | Tailscale Funnel makes the Gateway publicly reachable. | gateway.tailscale.mode | No |
gateway.control_ui.allowed_origins_required | Critical | Non-loopback Control UI is missing an explicit browser origin allowlist. | gateway.controlUi.allowedOrigins | No |
gateway.control_ui.host_header_origin_fallback | Warn/Critical | Host-header fallback weakens DNS rebinding protections. | gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback | No |
gateway.control_ui.insecure_auth | Warn | Control UI insecure-auth compatibility mode is enabled. | gateway.controlUi.allowInsecureAuth | No |
gateway.control_ui.device_auth_disabled | Critical | Device identity checks are disabled entirely. | gateway.controlUi.dangerouslyDisableDeviceAuth | No |
gateway.real_ip_fallback_enabled | Warn/Critical | Trusting X-Real-IP can enable source-IP spoofing via proxy misconfig. | gateway.allowRealIpFallback, gateway.trustedProxies | No |
discovery.mdns_full_mode | Warn/Critical | mDNS full mode advertises extra metadata on the local network. | discovery.mdns.mode, gateway.bind | No |
config.insecure_or_dangerous_flags | Warn | One or more insecure or dangerous debug flags are enabled. | see finding detail | No |
hooks.token_too_short | Warn | Hook ingress token is easier to brute-force. | hooks.token | No |
hooks.request_session_key_enabled | Warn/Critical | External callers can choose sessionKey, which can create persistence or collisions. | hooks.allowRequestSessionKey | No |
hooks.request_session_key_prefixes_missing | Warn/Critical | There is no bound on externally supplied session key shapes. | hooks.allowedSessionKeyPrefixes | No |
logging.redact_off | Warn | Sensitive values can leak into logs and status output. | logging.redactSensitive | Yes |
sandbox.docker_config_mode_off | Warn | Sandbox Docker config exists but sandboxing is inactive. | agents.*.sandbox.mode | No |
sandbox.dangerous_network_mode | Critical | Docker sandbox uses host or namespace-join networking. | agents.*.sandbox.docker.network | No |
tools.exec.host_sandbox_no_sandbox_defaults | Warn | Default exec host=sandbox resolves to host exec when sandbox is off. | tools.exec.host, agents.defaults.sandbox.mode | No |
tools.exec.host_sandbox_no_sandbox_agents | Warn | Per-agent exec host=sandbox resolves to host exec when sandbox is off. | agents.list[].tools.exec.host, agents.list[].sandbox.mode | No |
tools.exec.safe_bins_interpreter_unprofiled | Warn | safeBins includes interpreter/runtime bins without explicit safe profiles. | tools.exec.safeBins, tools.exec.safeBinProfiles | No |
skills.workspace.symlink_escape | Warn | A skill path in skills/** resolves outside the workspace root. | workspace skills/** filesystem state | No |
security.exposure.open_groups_with_elevated | Critical | Open groups plus elevated tools create a strong prompt-injection path. | channels.*.groupPolicy, tools.elevated.* | No |
security.exposure.open_groups_with_runtime_or_fs | Critical/Warn | Open groups can reach command/file tools without sandbox/workspace guards. | channels.*.groupPolicy, tools.profile/deny, tools.fs.workspaceOnly, agents.*.sandbox.mode | No |
security.trust_model.multi_user_heuristic | Warn | The config looks multi-user without matching isolation and tool hardening. | sandbox.mode, tool deny, workspace scoping | No |
tools.profile_minimal_overridden | Warn | Per-agent overrides bypass a minimal global profile. | agents.list[].tools.profile | No |
plugins.tools_reachable_permissive_policy | Warn | Extension/plugin tools are reachable in permissive contexts. | tools.profile plus tool allow/deny | No |
models.small_params | Critical/Info | Small or weak models plus tool access increase injection risk. | model choice + sandbox/tool policy | No |
Notes for auditors
openclaw security audit --fixis useful for some filesystem/logging issues, but it will not safely solve exposure problems for you.- For shared inboxes, treat
session.dmScopeand DM/group policy as primary security controls, not “nice to have” settings. - Reverse proxies, Tailscale Serve, browser-origin policy, and open group chats are where real-world OpenClaw incidents tend to get messy.
OpenClaw secure baseline config (starting point)
This file contains conservative baseline snippets for current OpenClaw builds.
OpenClaw config is usually ~/.openclaw/openclaw.json. Depending on install/profile it may be JSON or JSON5-like. Back it up before editing.Baseline goals
- keep the Gateway private
- require strong Gateway auth
- isolate DMs
- require explicit mentions in groups
- default tools to least privilege
- avoid accidental remote browser / node / automation exposure
1) Minimal local baseline
Good for a single-user local install that still wants sensible defaults.
{
gateway: {
mode: "local",
bind: "loopback",
port: 18789,
auth: { mode: "token", token: "replace-with-long-random-token" },
},
session: {
dmScope: "per-channel-peer",
},
channels: {
whatsapp: {
dmPolicy: "pairing",
groups: { "*": { requireMention: true } },
},
},
}2) Hardened inbox-facing baseline
This is the conservative starting point for support-style or user-facing agents.
{
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "replace-with-long-random-token" },
},
session: {
dmScope: "per-channel-peer",
},
tools: {
profile: "messaging",
deny: [
"group:automation", // gateway + cron
"group:runtime", // exec/bash/process
"group:fs", // read/write/edit/apply_patch
"sessions_spawn",
"sessions_send",
],
fs: { workspaceOnly: true },
exec: {
security: "deny",
ask: "always",
applyPatch: { workspaceOnly: true },
},
elevated: { enabled: false },
},
channels: {
whatsapp: { dmPolicy: "pairing", groups: { "*": { requireMention: true } } },
},
}3) Shared inbox / multi-account note
If more than one real person can DM the bot:
- use
session.dmScope: "per-channel-peer" - if the same provider has multiple bot accounts, prefer
per-account-channel-peer - keep
dmPolicy: "pairing"or explicit allowlists - do not combine broad runtime/fs/elevated tools with open DMs or open groups
4) Reverse proxy / non-loopback Control UI
If a reverse proxy fronts the Gateway, set trusted proxy IPs and explicit browser origins.
{
gateway: {
bind: "loopback",
trustedProxies: ["127.0.0.1"],
allowRealIpFallback: false,
auth: { mode: "token", token: "replace-with-long-random-token" },
controlUi: {
allowedOrigins: ["https://ui.example.com"],
},
},
}Notes:
- keep
gateway.controlUi.dangerouslyAllowHostHeaderOriginFallbackoff - keep
gateway.controlUi.dangerouslyDisableDeviceAuthoff - if the proxy is not on localhost, replace the trusted proxy IPs accordingly
5) Tailscale Serve note
tailscale.mode: "serve" can be a good remote-access pattern, but remember:
tailscale.mode: "funnel"is public and should be treated as a red flaggateway.auth.allowTailscalecan enable tokenless Control UI / WebSocket auth via Tailscale identity headers- that tokenless flow assumes the Gateway host itself is trusted
- if untrusted code may run on the host, or if any reverse proxy sits in front, disable
gateway.auth.allowTailscaleand require normal auth
6) Discovery and logging
Reduce ambient exposure:
{
discovery: {
mdns: { mode: "minimal" }, // or "off" if unused
},
logging: {
redactSensitive: true,
},
}7) Tool profile reminders
Current high-level profiles:
minimal->session_statusonlymessaging-> message + session reply/history/statuscoding-> filesystem + runtime + sessions + memory + imagefull-> unrestricted
For user-facing inbox bots, messaging is usually the right starting point, then narrow further with tools.deny.
Verification
After changes, re-run:
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probePlatform playbook: AWS EC2 (or similar cloud VM)
Threat assumptions
- Public cloud instances are scanned constantly.
- Misconfigured security groups, public IPs, and reverse proxies are common.
- A compromise can expose cloud credentials, bot credentials, and any attached storage.
Preferred deployment pattern
- Put the instance in a private subnet with no public IPv4 when possible.
- Access it via:
- AWS Systems Manager Session Manager, or
- SSH from a tightly controlled source, or
- VPN / Tailscale
- Keep the OpenClaw Gateway bound to loopback.
- If you need remote access, prefer Tailscale Serve or an SSH tunnel. Do not expose port 18789 directly.
Audit checks
1. Security groups / firewall
- confirm no inbound
18789/tcpfrom0.0.0.0/0or wide CIDRs - keep SSH tightly restricted, or disable it and use SSM
2. Gateway exposure
gateway.bindshould normally beloopbackopenclaw gateway probe --jsonshould show the intended target, not an accidentally public listener
3. Reverse proxies
- if nginx/Caddy/Traefik fronts the Gateway, configure
gateway.trustedProxies - keep
gateway.allowRealIpFallback: falseunless you absolutely need it - for non-loopback Control UI, set
gateway.controlUi.allowedOrigins
4. Cloud metadata and IAM
- prefer IMDSv2
- keep IAM role permissions minimal
- think about SSRF risk if runtime/web tools are enabled
5. Storage and logs
- encrypt attached volumes
- set log retention intentionally
- treat
~/.openclawand transcripts as sensitive application data
OpenClaw-specific cloud guidance
- keep DM pairing on; do not run shared inboxes with broad tools
- disable or minimise mDNS discovery (
minimaloroff) - do not publish browser control or node-management surfaces publicly
- if running in Docker, also apply
platform-docker.md
Verification
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probeAlso test from an external network that port 18789 is not reachable.
Platform playbook: Docker / Docker Compose
Threat assumptions
- Docker does not make an exposed service safe.
- If you publish
18789/tcpto0.0.0.0, the LAN or internet can still reach it. - Volume mounts often contain the most sensitive OpenClaw data: config, credentials, transcripts, workspace.
- Host networking or privileged containers turn a bot misconfiguration into a host incident quickly.
Audit checks
1. Published ports
- good:
127.0.0.1:18789->18789/tcp - risky:
0.0.0.0:18789->18789/tcp
Commands:
docker ps --format 'table {{.Names}} {{.Image}} {{.Ports}}'
docker compose ps || true
docker port openclaw-gateway 18789 || true2. Gateway auth and bind mode
- even on localhost publishing, keep Gateway auth enabled
- use
openclaw gateway probe --jsonto see the effective target the CLI can reach
3. Volume mounts
- identify mounts for
~/.openclawand workspace - avoid mounting your entire home directory
- keep host-side permissions on the mounted state dir restrictive
4. Container privileges
- avoid
privileged: true - avoid
network_mode: host - avoid unnecessary capabilities
- run as a non-root user where practical
5. Control UI and reverse proxy
- if a proxy fronts the container, configure
gateway.trustedProxies - for non-loopback Control UI, set
gateway.controlUi.allowedOrigins - do not enable Host-header origin fallback casually
Hardening actions
Publish localhost only
ports:
- "127.0.0.1:18789:18789"Reduce tool surface
For inbox-facing agents, start with tools.profile: "messaging" and deny runtime/fs/automation until a specific need appears.
Keep secrets out of the repo
Use host env files or a secrets manager, not committed config values.
Treat browser + exec as high risk
If untrusted users can message the bot, runtime/browser/node surfaces need especially tight controls.
Verification
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probeExternal reachability tests from another machine/network should fail unless the exposure is deliberate and defended.
Platform playbook: Mac mini or other always-on macOS host
Threat assumptions
- The host lives on a home or office LAN.
- Local networks are not automatically trustworthy: guest Wi-Fi, IoT devices, and shared machines matter.
- macOS hosts often have rich personal data, browser sessions, and other credentials.
Audit checks
1. Gateway should normally be loopback-bound. 2. Gateway auth should be enabled. 3. Control UI device-auth bypass should be off. 4. Discovery should be minimal or off unless there is a specific reason otherwise. 5. File permissions on OpenClaw state/config should be user-only. 6. Transcript/log retention should be intentional.
Useful commands:
openclaw security audit --deep --json
openclaw gateway probe --json
lsof -nP -iTCP -sTCP:LISTEN
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
/usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode
fdesetup status || trueHardening actions
1) Separate the bot from your main user
Best practice is a dedicated macOS user for OpenClaw with:
- no iCloud login
- no broad access to personal documents
- only the permissions/features the bot actually needs
2) Disk and OS security
- enable FileVault
- keep macOS updated
- keep the application firewall on
- use stealth mode when it fits the host’s role
3) Remote access
Preferred options:
- SSH tunnel to
127.0.0.1:18789 - Tailscale Serve
Avoid:
- router port-forwarding
- Tailscale Funnel for the Gateway
- arbitrary reverse proxies without
trustedProxiesand explicit origins
4) Tailscale Serve nuance
gateway.auth.allowTailscale can allow tokenless Control UI/WebSocket auth via Tailscale identity headers. That assumes the host itself is trusted.
If the macOS host also runs untrusted local code, or if another reverse proxy sits in front of the Gateway, disable gateway.auth.allowTailscale and require normal auth.
5) Tool and browser minimisation
- start from
tools.profile: "messaging"for inbox-facing bots - treat browser control as operator access
- keep runtime/fs/elevated tools off unless there is a narrow reason
Verification
openclaw security audit --deep --jsonshows no critical findingsopenclaw gateway probe --jsonmatches the intended local/tailnet target- listener checks show 18789 on loopback only unless there is a deliberate exception
Platform playbook: Personal laptop (macOS / Windows / Linux)
Why laptops are special
- They roam across networks.
- They often hold personal data, browser sessions, and development secrets.
- They are more likely to run other untrusted tools or experiments locally.
Preferred pattern
- Do not run OpenClaw 24/7 on your primary laptop unless there is a strong reason.
- Prefer isolation:
- Docker with careful mounts and networking
- a VM
- a separate OS user with minimal access to your personal files
Audit checks
1. Is the Gateway loopback-bound? 2. Is auth enabled? 3. Are DMs locked down with pairing/allowlists? 4. Are groups mention-gated? 5. Are runtime/fs/elevated tools constrained? 6. Is transcript/log retention intentional?
Hardening actions
Common to all laptops
- keep the Gateway local-only
- avoid running the bot on untrusted networks
- turn it off when you do not need it
- prefer
session.dmScope: "per-channel-peer"for any multi-user scenario - start from a conservative tool profile
macOS laptops
- same guidance as the Mac mini playbook, with extra emphasis on travel and untrusted Wi-Fi
- FileVault and the macOS firewall should both be on
Windows laptops
- prefer WSL2 for shell-driven audit and management flows
- keep Defender and BitLocker enabled
- keep the WSL distro updated and minimal
Linux laptops
- use a host firewall (
ufw/nftables) - consider a dedicated user or container boundary for OpenClaw
Verification
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probeConfirm there is no inbound exposure beyond localhost or your intentional tailnet path.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Collect mostly read-only OpenClaw security diagnostics.
Usage:
collect_openclaw_audit.sh --out DIR
The script writes a timestamped folder inside DIR:
DIR/openclaw-audit-<UTC timestamp>/
Safety:
- Does not run any --fix operations.
- Avoids copying credential files.
- Collects shareable CLI diagnostics plus basic host/network metadata.
- Review outputs before sharing externally.
Examples:
bash "{baseDir}/scripts/collect_openclaw_audit.sh" --out ./openclaw-audit
EOF
}
OUT_DIR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--out)
OUT_DIR="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${OUT_DIR}" ]]; then
echo "Missing --out <dir>" >&2
usage >&2
exit 2
fi
TS="$(date -u +"%Y%m%dT%H%M%SZ")"
ROOT="${OUT_DIR%/}/openclaw-audit-${TS}"
mkdir -p "${ROOT}"
log() { echo "[collect] $*"; }
write_note() {
local name="$1"; shift
printf '%s
' "$*" > "${ROOT}/${name}.txt"
}
run_cmd() {
local name="$1"; shift
local file="${ROOT}/${name}.txt"
log "Running: $*"
{
echo "$ $*"
"$@"
} > "${file}" 2>&1 || {
echo "[warn] command failed (continuing): $*" >> "${file}"
return 0
}
}
run_cmd_maybe_sudo() {
local name="$1"; shift
local file="${ROOT}/${name}.txt"
if command -v sudo >/dev/null 2>&1; then
log "Running (sudo -n): $*"
{
echo "$ sudo -n $*"
sudo -n "$@"
} > "${file}" 2>&1 || {
echo "[info] sudo not available without password (skipped): $*" >> "${file}"
return 0
}
else
echo "[info] sudo not installed; skipped: $*" > "${file}"
fi
}
# Host basics
run_cmd "host_whoami" whoami
run_cmd "host_uname" uname -a
if command -v sw_vers >/dev/null 2>&1; then
run_cmd "host_sw_vers" sw_vers
if [[ -x /usr/libexec/ApplicationFirewall/socketfilterfw ]]; then
run_cmd "macos_firewall_state" /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
run_cmd "macos_firewall_stealth" /usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode
fi
run_cmd "macos_filevault" fdesetup status
fi
if [[ -f /etc/os-release ]]; then
run_cmd "host_os_release" cat /etc/os-release
fi
# Network listeners
if command -v lsof >/dev/null 2>&1; then
run_cmd "net_lsof_listen" lsof -nP -iTCP -sTCP:LISTEN
elif command -v ss >/dev/null 2>&1; then
run_cmd "net_ss_listen" ss -ltnp
elif command -v netstat >/dev/null 2>&1; then
run_cmd "net_netstat_listen" netstat -anv
fi
# Linux firewall snapshot (best-effort)
if [[ -f /etc/os-release ]]; then
run_cmd_maybe_sudo "linux_ufw_status" ufw status verbose
run_cmd_maybe_sudo "linux_nft_ruleset" nft list ruleset
run_cmd_maybe_sudo "linux_iptables_rules" iptables -S
fi
# Docker / container clues
if command -v docker >/dev/null 2>&1; then
run_cmd "docker_ps" docker ps --format 'table {{.Names}} {{.Image}} {{.Ports}}'
run_cmd "docker_compose_ps" docker compose ps
run_cmd "docker_port_openclaw_gateway_18789" docker port openclaw-gateway 18789
fi
if command -v podman >/dev/null 2>&1; then
run_cmd "podman_ps" podman ps --format 'table {{.Names}} {{.Image}} {{.Ports}}'
fi
if ! command -v openclaw >/dev/null 2>&1; then
write_note "openclaw_missing" "openclaw not found on PATH; collected host-level data only."
(
cd "${ROOT}"
find . -maxdepth 1 -type f -printf '%f\n' | sort > manifest.txt
)
log "openclaw missing; collected host info only"
exit 0
fi
# OpenClaw core diagnostics
run_cmd "openclaw_version" openclaw --version
run_cmd "openclaw_status_all" openclaw status --all
run_cmd "openclaw_status_deep" openclaw status --deep
run_cmd "openclaw_doctor" openclaw doctor
run_cmd "openclaw_gateway_status" openclaw gateway status
run_cmd "openclaw_gateway_probe_json" openclaw gateway probe --json
run_cmd "openclaw_channels_status_probe" openclaw channels status --probe
run_cmd "openclaw_health_json" openclaw health --json
run_cmd "openclaw_security_audit_json" openclaw security audit --json
run_cmd "openclaw_security_audit_deep_json" openclaw security audit --deep --json
# Backup readiness (read-only dry runs)
run_cmd "openclaw_backup_create_dry_run_json" openclaw backup create --dry-run --json
run_cmd "openclaw_backup_only_config_dry_run_json" openclaw backup create --only-config --dry-run --json
# Safe targeted config reads
run_cmd "openclaw_config_gateway_bind" openclaw config get gateway.bind
run_cmd "openclaw_config_gateway_auth_mode" openclaw config get gateway.auth.mode
run_cmd "openclaw_config_gateway_auth_allow_tailscale" openclaw config get gateway.auth.allowTailscale
run_cmd "openclaw_config_gateway_controlui_allowed_origins" openclaw config get gateway.controlUi.allowedOrigins
run_cmd "openclaw_config_gateway_trusted_proxies" openclaw config get gateway.trustedProxies
run_cmd "openclaw_config_gateway_allow_real_ip_fallback" openclaw config get gateway.allowRealIpFallback
run_cmd "openclaw_config_discovery_mdns_mode" openclaw config get discovery.mdns.mode
run_cmd "openclaw_config_session_dm_scope" openclaw config get session.dmScope
run_cmd "openclaw_config_tools_profile" openclaw config get tools.profile
run_cmd "openclaw_config_tools_fs_workspace_only" openclaw config get tools.fs.workspaceOnly
run_cmd "openclaw_config_tools_exec_security" openclaw config get tools.exec.security
run_cmd "openclaw_config_tools_elevated_enabled" openclaw config get tools.elevated.enabled
run_cmd "openclaw_config_channels_defaults_dm_policy" openclaw config get channels.defaults.dmPolicy
run_cmd "openclaw_config_channels_defaults_group_policy" openclaw config get channels.defaults.groupPolicy
run_cmd "openclaw_config_logging_redact_sensitive" openclaw config get logging.redactSensitive
# Supply-chain visibility
run_cmd "openclaw_skills_eligible_json" openclaw skills list --eligible --json
run_cmd "openclaw_plugins_list_json" openclaw plugins list --json
# State directory metadata (do not copy secrets)
STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
if [[ -d "${STATE_DIR}" ]]; then
run_cmd "openclaw_state_ls" ls -la "${STATE_DIR}"
if command -v stat >/dev/null 2>&1; then
run_cmd "openclaw_state_stat" stat "${STATE_DIR}" "${STATE_DIR}/openclaw.json"
fi
fi
(
cd "${ROOT}"
find . -maxdepth 1 -type f -printf '%f
' | sort > manifest.txt
)
log "Done. Output: ${ROOT}"
#!/usr/bin/env python3
"""Redact an OpenClaw config file for safer sharing.
The active OpenClaw config is often JSON5-like (`~/.openclaw/openclaw.json` by
default), so this script tries strict JSON first, then an optional JSON5 parser,
then falls back to regex-based redaction on the raw text.
What it redacts (best-effort):
- Secret-like keys: token, password, secret, api key, cookie, session key, etc.
- String values that strongly resemble long secrets or JWT-style tokens.
- Query-string secrets in URLs such as ?token=... or ?access_token=...
Examples:
python3 "{baseDir}/scripts/redact_openclaw_config.py" ~/.openclaw/openclaw.json > openclaw.json.redacted
cat ~/.openclaw/openclaw.json | python3 "{baseDir}/scripts/redact_openclaw_config.py" - > openclaw.json.redacted
Always review the redacted output before sharing it.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from typing import Any, Callable, Optional
SENSITIVE_KEY_RE = re.compile(
r"(token|password|secret|api[_-]?key|apikey|client[_-]?secret|private[_-]?key|session[_-]?key|cookie|bearer|access[_-]?token|refresh[_-]?token)\b",
re.IGNORECASE,
)
JWT_LIKE_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
HEXISH_RE = re.compile(r"^[A-Fa-f0-9]{32,}$")
ALNUMISH_RE = re.compile(r"^[A-Za-z0-9_\-]{24,}$")
URL_QS_SECRET_RE = re.compile(
r"(?P<prefix>[?&](?:token|password|api[_-]?key|apikey|key|access_token|refresh_token|session[_-]?key)=)(?P<val>[^&\s#]+)",
re.IGNORECASE,
)
def mask(s: str) -> str:
s = s or ""
if len(s) <= 8:
return "***"
return f"{s[:4]}…{s[-4:]}"
def looks_secret(s: str) -> bool:
if len(s) < 24:
return False
if s.startswith(("http://", "https://", "/", "./", "../", "~/")):
return False
return bool(JWT_LIKE_RE.match(s) or HEXISH_RE.match(s) or ALNUMISH_RE.match(s))
def redact_string(s: str) -> str:
if looks_secret(s):
return mask(s)
return URL_QS_SECRET_RE.sub(lambda m: m.group("prefix") + mask(m.group("val")), s)
def redact_obj(obj: Any) -> Any:
if isinstance(obj, dict):
out: dict[str, Any] = {}
for key, value in obj.items():
skey = str(key)
if SENSITIVE_KEY_RE.search(skey):
if isinstance(value, str):
out[skey] = redact_string(value)
else:
out[skey] = "***"
else:
out[skey] = redact_obj(value)
return out
if isinstance(obj, list):
return [redact_obj(item) for item in obj]
if isinstance(obj, str):
return redact_string(obj)
return obj
def try_json5_parser() -> Optional[Callable[[str], Any]]:
for module_name in ("json5", "pyjson5"):
try:
module = __import__(module_name)
loads = getattr(module, "loads", None)
if callable(loads):
return loads # type: ignore[return-value]
except Exception:
continue
return None
def text_fallback_redact(raw: str) -> str:
redacted = URL_QS_SECRET_RE.sub(lambda m: m.group("prefix") + mask(m.group("val")), raw)
quoted_kv_re = re.compile(
r"(?P<key>\b[\w.-]*(?:token|password|secret|api[_-]?key|apikey|client[_-]?secret|private[_-]?key|session[_-]?key|cookie|bearer|access[_-]?token|refresh[_-]?token)[\w.-]*\b)"
r"(?P<ws>\s*:\s*)"
r"(?P<val>(?:\"[^\"]*\"|'[^']*'))",
re.IGNORECASE,
)
def repl_quoted(match: re.Match[str]) -> str:
key = match.group("key")
ws = match.group("ws")
val = match.group("val")
if val.startswith('"'):
inner = val[1:-1]
return f'{key}{ws}"{mask(inner)}"'
inner = val[1:-1]
return f"{key}{ws}'{mask(inner)}'"
redacted = quoted_kv_re.sub(repl_quoted, redacted)
bare_kv_re = re.compile(
r"(?P<key>\b[\w.-]*(?:token|password|secret|api[_-]?key|apikey|client[_-]?secret|private[_-]?key|session[_-]?key|cookie|bearer|access[_-]?token|refresh[_-]?token)[\w.-]*\b)"
r"(?P<ws>\s*:\s*)"
r"(?P<val>[A-Za-z0-9._-]{12,})",
re.IGNORECASE,
)
def repl_bare(match: re.Match[str]) -> str:
value = match.group("val")
redacted_value = mask(value) if looks_secret(value) else value
return f"{match.group('key')}{match.group('ws')}{redacted_value}"
return bare_kv_re.sub(repl_bare, redacted)
def load_raw(path: str) -> str:
if path == "-":
return sys.stdin.read()
with open(path, "r", encoding="utf-8", errors="replace") as handle:
return handle.read()
def main() -> None:
parser = argparse.ArgumentParser(
description="Redact an OpenClaw config file before sharing it.",
epilog="The tool prefers structured JSON/JSON5 redaction when possible and falls back to raw-text redaction otherwise.",
)
parser.add_argument(
"path",
nargs="?",
default="-",
help="Path to the config file, or '-' to read from stdin.",
)
parser.add_argument(
"--text-fallback-only",
action="store_true",
help="Skip JSON/JSON5 parsing and redact the raw text directly.",
)
args = parser.parse_args()
raw = load_raw(args.path)
if args.text_fallback_only:
sys.stdout.write(text_fallback_redact(raw))
if not raw.endswith("\n"):
sys.stdout.write("\n")
return
parsed = False
obj: Any = None
try:
obj = json.loads(raw)
parsed = True
except Exception:
parsed = False
if not parsed:
json5_loads = try_json5_parser()
if json5_loads is not None:
try:
obj = json5_loads(raw)
parsed = True
except Exception:
parsed = False
if parsed:
json.dump(redact_obj(obj), sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
return
sys.stdout.write(text_fallback_redact(raw))
if not raw.endswith("\n"):
sys.stdout.write("\n")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Render a Markdown report from collected OpenClaw audit artefacts.
The renderer is intentionally tolerant:
- reads capture files produced by collect_openclaw_audit.sh
- extracts JSON from CLI captures when possible
- merges audit findings from regular + deep runs
- enriches findings with assets/openclaw_checkid_map.json
- fills in environment clues from targeted config-get captures
Usage:
python3 "{baseDir}/scripts/render_report.py" --input ./openclaw-audit --output ./openclaw-security-report.md
"""
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
SEV_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4, "unknown": 9}
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
@dataclass
class Capture:
name: str
path: Path
raw: str
body: str
def strip_cmd_banner(text: str) -> str:
text = ANSI_RE.sub("", text)
lines = text.splitlines()
if lines and lines[0].startswith("$ "):
lines = lines[1:]
return "\n".join(lines).strip()
def load_capture(path: Path) -> Optional[Capture]:
if not path.exists():
return None
raw = path.read_text(encoding="utf-8", errors="replace")
return Capture(name=path.stem, path=path, raw=raw, body=strip_cmd_banner(raw))
def newest_audit_folder(root: Path) -> Path:
if root.is_dir():
kids = [p for p in root.iterdir() if p.is_dir() and p.name.startswith("openclaw-audit-")]
if kids:
return sorted(kids, key=lambda p: p.name)[-1]
return root
def clean_scalar_text(text: str) -> str:
lines: List[str] = []
for line in text.splitlines():
if line.startswith("[warn]") or line.startswith("[info]"):
continue
lines.append(line.rstrip())
return "\n".join(lines).strip()
def load_json_text(txt: str) -> Any:
body = clean_scalar_text(txt)
candidate_starts = [i for ch in ("{", "[") for i in [body.find(ch)] if i != -1]
for idx in sorted(candidate_starts):
try:
return json.loads(body[idx:])
except Exception:
continue
return None
def load_json_capture(path: Path) -> Any:
capture = load_capture(path)
if capture is None:
return None
return load_json_text(capture.body)
def load_json_file(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def normalise_sev(raw: str) -> str:
s = (raw or "").strip().lower()
if not s:
return "unknown"
if "/" in s:
parts = [p.strip() for p in s.split("/") if p.strip()]
if "critical" in parts:
return "critical"
if "high" in parts:
return "high"
if "warn" in parts or "warning" in parts or "medium" in parts:
return "medium"
if "low" in parts:
return "low"
if "info" in parts:
return "info"
return parts[0] if parts else "unknown"
if s in ("warn", "warning"):
return "medium"
if s == "crit":
return "critical"
return s if s in SEV_ORDER else s
def extract_findings(obj: Any) -> List[Dict[str, Any]]:
if obj is None:
return []
if isinstance(obj, dict):
for key in ("findings", "checks", "results", "issues"):
value = obj.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
if obj and all(isinstance(v, dict) for v in obj.values()):
out: List[Dict[str, Any]] = []
for key, value in obj.items():
item = dict(value)
item.setdefault("checkId", key)
out.append(item)
return out
if isinstance(obj, list):
return [item for item in obj if isinstance(item, dict)]
return []
def pick_text(finding: Dict[str, Any]) -> str:
for key in ("title", "summary", "message", "description"):
value = finding.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return "(no summary provided)"
def dedupe_findings(findings: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
seen: set[Tuple[str, str, str]] = set()
out: List[Dict[str, Any]] = []
for finding in findings:
check_id = str(finding.get("checkId") or finding.get("id") or "").strip()
summary = pick_text(finding)
sev = normalise_sev(str(finding.get("severity") or finding.get("level") or finding.get("risk") or ""))
key = (check_id, summary, sev)
if key in seen:
continue
seen.add(key)
out.append(finding)
return out
def sort_key(finding: Dict[str, Any]) -> Tuple[int, str, str]:
sev = normalise_sev(str(finding.get("severity") or finding.get("level") or finding.get("risk") or "unknown"))
order = SEV_ORDER.get(sev, 8)
check_id = str(finding.get("checkId") or finding.get("id") or "")
return (order, check_id, pick_text(finding))
def severity_counts(findings: List[Dict[str, Any]]) -> Dict[str, int]:
counts: Dict[str, int] = {}
for finding in findings:
sev = normalise_sev(str(finding.get("severity") or finding.get("level") or finding.get("risk") or "unknown"))
counts[sev] = counts.get(sev, 0) + 1
return counts
def load_checkid_map(skill_root: Path) -> Dict[str, Any]:
path = skill_root / "assets" / "openclaw_checkid_map.json"
if path.exists():
obj = load_json_file(path)
if isinstance(obj, dict):
return obj
return {}
def read_capture_body(in_dir: Path, stem: str) -> str:
capture = load_capture(in_dir / f"{stem}.txt")
if capture is None:
return ""
return clean_scalar_text(capture.body)
def read_config_value(in_dir: Path, stem: str) -> Optional[Any]:
body = read_capture_body(in_dir, stem)
if not body:
return None
try:
return json.loads(body)
except Exception:
lowered = body.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if lowered == "null":
return None
return body
def one_line(value: Any) -> str:
if value is None:
return "(unset)"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
value = value.strip()
return value or "(empty)"
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def detect_os(in_dir: Path) -> str:
sw_vers = read_capture_body(in_dir, "host_sw_vers")
if sw_vers:
product_name = ""
product_version = ""
for line in sw_vers.splitlines():
if ":" not in line:
continue
key, value = [part.strip() for part in line.split(":", 1)]
if key == "ProductName":
product_name = value
elif key == "ProductVersion":
product_version = value
if product_name or product_version:
return " ".join(part for part in (product_name, product_version) if part).strip()
os_release = read_capture_body(in_dir, "host_os_release")
if os_release:
for line in os_release.splitlines():
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
for line in os_release.splitlines():
if line.startswith("NAME="):
return line.split("=", 1)[1].strip().strip('"')
uname = read_capture_body(in_dir, "host_uname")
return uname or "(unknown)"
def parse_version(in_dir: Path) -> str:
body = read_capture_body(in_dir, "openclaw_version")
if not body:
return "(unknown)"
lines = [line.strip() for line in body.splitlines() if line.strip()]
return lines[-1] if lines else "(unknown)"
def parse_gateway_runtime(in_dir: Path) -> str:
body = read_capture_body(in_dir, "openclaw_gateway_status")
if not body:
return "(not collected)"
runtime = ""
rpc = ""
for line in body.splitlines():
line = line.strip()
if line.lower().startswith("runtime:"):
runtime = line.split(":", 1)[1].strip()
elif line.lower().startswith("rpc probe:"):
rpc = line.split(":", 1)[1].strip()
if runtime and rpc:
return f"Runtime: {runtime}; RPC probe: {rpc}"
if runtime:
return f"Runtime: {runtime}"
return body.splitlines()[0].strip()
def build_environment_rows(in_dir: Path) -> List[Tuple[str, str]]:
rows = [
("OS", detect_os(in_dir)),
("OpenClaw version", parse_version(in_dir)),
("Gateway status", parse_gateway_runtime(in_dir)),
("Gateway bind", one_line(read_config_value(in_dir, "openclaw_config_gateway_bind"))),
("Gateway auth mode", one_line(read_config_value(in_dir, "openclaw_config_gateway_auth_mode"))),
("Gateway auth.allowTailscale", one_line(read_config_value(in_dir, "openclaw_config_gateway_auth_allow_tailscale"))),
("Control UI allowed origins", one_line(read_config_value(in_dir, "openclaw_config_gateway_controlui_allowed_origins"))),
("Trusted proxies", one_line(read_config_value(in_dir, "openclaw_config_gateway_trusted_proxies"))),
("allowRealIpFallback", one_line(read_config_value(in_dir, "openclaw_config_gateway_allow_real_ip_fallback"))),
("Discovery mDNS mode", one_line(read_config_value(in_dir, "openclaw_config_discovery_mdns_mode"))),
("Session dmScope", one_line(read_config_value(in_dir, "openclaw_config_session_dm_scope"))),
("Default DM policy", one_line(read_config_value(in_dir, "openclaw_config_channels_defaults_dm_policy"))),
("Default group policy", one_line(read_config_value(in_dir, "openclaw_config_channels_defaults_group_policy"))),
("Tools profile", one_line(read_config_value(in_dir, "openclaw_config_tools_profile"))),
("FS workspaceOnly", one_line(read_config_value(in_dir, "openclaw_config_tools_fs_workspace_only"))),
("Exec security", one_line(read_config_value(in_dir, "openclaw_config_tools_exec_security"))),
("Elevated tools enabled", one_line(read_config_value(in_dir, "openclaw_config_tools_elevated_enabled"))),
("Logging redactSensitive", one_line(read_config_value(in_dir, "openclaw_config_logging_redact_sensitive"))),
]
docker_ps = read_capture_body(in_dir, "docker_ps")
if docker_ps:
rows.append(("Docker present", "yes"))
return rows
def overall_risk(counts: Dict[str, int]) -> str:
if counts.get("critical"):
return "Critical"
if counts.get("high"):
return "High"
if counts.get("medium"):
return "Moderate"
if counts.get("low"):
return "Low"
if counts.get("info") or counts.get("unknown"):
return "Informational"
return "No parsed findings"
def likely_verification_for(check_id: str) -> str:
if check_id.startswith("gateway.") or check_id.startswith("security.exposure.") or check_id.startswith("discovery."):
return "`openclaw security audit --deep --json`, `openclaw gateway probe --json`, `openclaw channels status --probe`"
if check_id.startswith("fs.") or check_id.startswith("logging."):
return "`openclaw security audit --json` and re-check filesystem permissions"
if check_id.startswith("sandbox.") or check_id.startswith("tools."):
return "`openclaw security audit --deep --json` plus targeted `openclaw config get ...`"
return "`openclaw security audit --deep --json`"
def markdown_escape(text: str) -> str:
return text.replace("|", "\\|").replace("\n", " ")
def main() -> None:
parser = argparse.ArgumentParser(description="Render a Markdown report from OpenClaw audit artefacts.")
parser.add_argument("--input", required=True, help="Audit folder or parent folder produced by collect_openclaw_audit.sh")
parser.add_argument("--output", required=True, help="Output Markdown file")
args = parser.parse_args()
in_path = Path(args.input)
in_dir = newest_audit_folder(in_path)
out_path = Path(args.output)
if not in_dir.exists() or not in_dir.is_dir():
raise SystemExit(f"Input directory not found: {in_dir}")
skill_root = Path(__file__).resolve().parents[1]
check_map = load_checkid_map(skill_root)
audit_sources = [
in_dir / "openclaw_security_audit_deep_json.txt",
in_dir / "openclaw_security_audit_json.txt",
]
findings: List[Dict[str, Any]] = []
parsed_files: List[str] = []
for path in audit_sources:
obj = load_json_capture(path)
if obj is not None:
parsed_files.append(path.name)
findings.extend(extract_findings(obj))
findings = dedupe_findings(findings)
findings_sorted = sorted(findings, key=sort_key)
counts = severity_counts(findings_sorted)
env_rows = build_environment_rows(in_dir)
report: List[str] = []
report.append("# OpenClaw Security Audit Report\n\n")
report.append("## Executive summary\n\n")
report.append(f"- **Overall risk rating:** {overall_risk(counts)}\n")
report.append(f"- **OpenClaw version:** {parse_version(in_dir)}\n")
report.append(f"- **Audit artefacts folder:** `{in_dir}`\n")
if parsed_files:
report.append(f"- **Parsed audit files:** {', '.join(f'`{name}`' for name in parsed_files)}\n")
else:
report.append("- **Parsed audit files:** none (review raw artefacts manually)\n")
if findings_sorted:
report.append("- **Most urgent issues:**\n")
for finding in findings_sorted[:3]:
check_id = str(finding.get("checkId") or finding.get("id") or "(unknown)")
summary = pick_text(finding)
sev = normalise_sev(str(finding.get("severity") or finding.get("level") or finding.get("risk") or "unknown"))
report.append(f" - `{check_id}` ({sev}): {summary}\n")
else:
report.append("- **Most urgent issues:** none parsed automatically; inspect raw audit outputs.\n")
report.append("\n")
report.append("## Environment overview\n\n")
for key, value in env_rows:
report.append(f"- **{key}:** {value}\n")
report.append("\n")
report.append("## Finding counts\n\n")
if counts:
for sev in ("critical", "high", "medium", "low", "info", "unknown"):
if sev in counts:
report.append(f"- **{sev}:** {counts[sev]}\n")
else:
report.append("- No findings parsed.\n")
report.append("\n")
report.append("## Findings\n\n")
if not findings_sorted:
report.append("No findings were parsed from the collected JSON. Review these files manually:\n\n")
for path in audit_sources:
report.append(f"- `{path.name}`\n")
report.append("\n")
else:
report.append("| Severity | Check ID | Summary | Why it matters | Likely fix area | Auto-fix | Verification |\n")
report.append("|---|---|---|---|---|---:|---|\n")
for finding in findings_sorted[:400]:
sev = normalise_sev(str(finding.get("severity") or finding.get("level") or finding.get("risk") or "unknown"))
check_id = str(finding.get("checkId") or finding.get("id") or "").strip() or "(unknown)"
summary = markdown_escape(pick_text(finding))
enrich = check_map.get(check_id, {}) if isinstance(check_map, dict) else {}
why = ""
likely_fix = ""
auto_fix = ""
if isinstance(enrich, dict):
why = str(enrich.get("why") or "")
likely_fix = str(enrich.get("primary_fix") or "")
if "auto_fix" in enrich:
auto_fix = "yes" if enrich.get("auto_fix") else "no"
for key in ("why", "reason"):
value = finding.get(key)
if isinstance(value, str) and value.strip():
why = value.strip()
break
for key in ("primaryFix", "fix", "path", "configPath"):
value = finding.get(key)
if isinstance(value, str) and value.strip():
likely_fix = value.strip()
break
if isinstance(value, dict):
maybe_path = value.get("path")
if isinstance(maybe_path, str) and maybe_path.strip():
likely_fix = maybe_path.strip()
break
if isinstance(finding.get("autoFix"), bool):
auto_fix = "yes" if finding.get("autoFix") else "no"
report.append(
"| {sev} | {check_id} | {summary} | {why} | {fix} | {auto_fix} | {verify} |\n".format(
sev=sev,
check_id=markdown_escape(check_id),
summary=summary,
why=markdown_escape(why or "(see finding detail)"),
fix=markdown_escape(likely_fix or "(see config / finding detail)"),
auto_fix=auto_fix or "",
verify=markdown_escape(likely_verification_for(check_id)),
)
)
report.append("\n")
report.append("## Remediation plan\n\n")
report.append("### Phase 1 — Stop the bleeding (same day)\n\n")
report.append("1. Fix any public exposure, missing auth, or open groups combined with dangerous tools.\n")
report.append("2. Back up the current state before invasive changes: `openclaw backup create --verify`.\n")
report.append("3. Remove insecure Control UI / reverse-proxy settings and lock down DM/group access.\n\n")
report.append("### Phase 2 — Reduce blast radius (this week)\n\n")
report.append("1. Move inbox-facing agents to a conservative tool profile and deny unnecessary groups.\n")
report.append("2. Tighten workspace, exec, sandbox, node, and plugin trust settings.\n")
report.append("3. Review writable paths, local skill/plugin sources, transcript retention, and log redaction.\n\n")
report.append("### Phase 3 — Operationalise (ongoing)\n\n")
report.append("- Keep OpenClaw and the host OS updated.\n")
report.append("- Rotate gateway credentials after any suspected leakage.\n")
report.append("- Re-run `openclaw security audit --deep --json` after major config changes.\n")
report.append("- Keep an intentional backup/restore routine for config, state, and workspace.\n\n")
report.append("## Key raw artefacts\n\n")
for stem in (
"openclaw_status_all",
"openclaw_status_deep",
"openclaw_gateway_status",
"openclaw_gateway_probe_json",
"openclaw_channels_status_probe",
"openclaw_security_audit_json",
"openclaw_security_audit_deep_json",
"openclaw_health_json",
):
path = in_dir / f"{stem}.txt"
if path.exists():
report.append(f"- `{path.name}`\n")
report.append("\n")
report.append("## Residual risk notes\n\n")
report.append(
"Even after hardening, any agent that can read untrusted content and call tools carries prompt-injection and social-engineering risk. "
"Document which surfaces are intentionally open, which tools remain enabled, and what the recovery path is if the Gateway host or credentials are compromised.\n"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("".join(report), encoding="utf-8")
if __name__ == "__main__":
main()