Skill: tristanmanchester/audit-openclaw-security (version 2.0.1)

--- name: audit-openclaw-security description: Audit and harden OpenClaw deployments and interpret `openclaw security audit` findings. Use when the user wants to secure OpenClaw, review gateway exposure/auth/reverse proxies/Tailscale Serve or Funnel, check DM/group access (pairing, allowlists, mention gating, `session.dmScope`), minimise tool permissions and sandboxing, review plugins/skills/secrets/transcripts/log retention, or lock down Docker/macOS/laptop/EC2 installs. Not for generic OS, Docker, or cloud hardening unrelated to OpenClaw. license: MIT compatibility: Best with local shell access (Claude Code, OpenClaw desktop/CLI, or a similar agent runtime). In chat-only environments, ask the user to run the bundled commands locally and share redacted outputs. metadata: {"author":"community","version":"2.1.0","validated_for":"2026.3.8","upstream":"OpenClaw docs + Agent Skills guidance (validated 2026-03-11)"} --- # 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 --all` - `openclaw status --deep` - `openclaw gateway probe --json` - `openclaw security audit --json` - `openclaw 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-workspace` if the config is invalid but you still need state + creds - use `--only-config` if 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.dmScope` is isolated appropriately: - `per-channel-peer` for most multi-user setups - `per-account-channel-peer` when 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:automation` on untrusted surfaces - `tools.fs.workspaceOnly: true` - `tools.exec.security: "deny"` or at least approval-gated - `tools.elevated.enabled: false` unless 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 ladders - `references/openclaw-audit-checks.md` — current high-signal `checkId` glossary - `references/openclaw-baseline-config.md` — secure baseline snippets - `references/platform-mac-mini.md` - `references/platform-personal-laptop.md` - `references/platform-docker.md` - `references/platform-aws-ec2.md` - `assets/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 ```bash 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 --json ``` ### Helpful extras ```bash 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 --json ``` ### Safe targeted config reads Prefer targeted reads over a full config dump: ```bash 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.redactSensitive ``` ### DM / group follow-up checks If the issue is “the bot is online but DMs or groups behave strangely”, check pairing and mention gating: ```bash 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: ```bash python3 "{baseDir}/scripts/redact_openclaw_config.py" ~/.openclaw/openclaw.json > openclaw.json.redacted ``` ### Host / network snapshots **macOS** ```bash whoami sw_vers uname -a lsof -nP -iTCP -sTCP:LISTEN /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate /usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode fdesetup status || true ``` **Linux / cloud VM** ```bash whoami cat /etc/os-release uname -a ss -ltnp sudo ufw status verbose || true sudo nft list ruleset || true sudo iptables -S || true ``` **Docker / Compose** ```bash docker ps --format 'table {{.Names}} {{.Image}} {{.Ports}}' docker compose ps || true docker port openclaw-gateway 18789 || true ``` ## Mode B — Automated local audit (shell access) Run the bundled collector and report renderer: ```bash bash "{baseDir}/scripts/collect_openclaw_audit.sh" --out ./openclaw-audit python3 "{baseDir}/scripts/render_report.py" --input ./openclaw-audit --output ./openclaw-security-report.md ``` Then 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 --deep` - `openclaw gateway probe --json` - `openclaw channels status --probe` - targeted safe `config get` values - 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_required` - `gateway.control_ui.host_header_origin_fallback` - `gateway.real_ip_fallback_enabled` - `config.insecure_or_dangerous_flags` - `sandbox.dangerous_network_mode` - `tools.exec.host_sandbox_no_sandbox_defaults` - `tools.exec.host_sandbox_no_sandbox_agents` - `tools.exec.safe_bins_interpreter_unprofiled` - `skills.workspace.symlink_escape` - `security.exposure.open_groups_with_elevated` - `security.exposure.open_groups_with_runtime_or_fs` - `security.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 from `gateway.auth.*`. - If the user needs a new shared secret, `openclaw doctor --generate-gateway-token` is 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: false` unless 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.allowTailscale` can 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.allowTailscale` and require token/password or trusted-proxy auth. ### 4) DM and group isolation - Use `dmPolicy: "pairing"` or `allowlist` for 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.mentionPatterns` where 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: true` - `tools.exec.security: "deny"` and `ask: "always"` - `tools.exec.applyPatch.workspaceOnly: true` - `tools.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` / `cron` tools 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: ```bash openclaw doctor openclaw doctor --fix ``` Even 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 target` - `Listening` - `Last 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: ```bash openclaw channels status --probe openclaw pairing list <channel> ``` Common root causes: - pending pairing approval - `dmPolicy` too strict for the expected sender - provider-side permission or token drift ### “Groups are silent” Check: - `groupPolicy` - `requireMention` - `mentionPatterns` - 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 --json` findings.” - “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

path: CHANGELOG.md
content: # 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.md` frontmatter 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 --deep`
  - `openclaw gateway probe --json`
  - `openclaw channels status --probe`
  - `openclaw 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.dmScope` guidance for shared inbox and multi-account setups
- refreshed platform playbooks
- upgraded bundled scripts for agentic use:
  - `--help` support
  - safer config redaction flow
  - richer rendered report with environment clues and merged audit findings

## Bundle versioning

- Canonical skill name remains `audit-openclaw-security` so the directory still matches the `name` field.
- Bundle metadata version is now `2.1.0`.
- Validated against OpenClaw `2026.3.8`.


path: references/command-cheatsheet.md
content: # Command cheat sheet (audit focus, OpenClaw 2026.3.8)

Run these on the OpenClaw host.

## Fast command ladder

```bash
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 --json
```

## Before remediation

Back up first:

```bash
openclaw backup create --verify
```

If the config is invalid but you still want a safety copy:

```bash
openclaw backup create --no-include-workspace
openclaw backup create --only-config
```

Read-only dry runs:

```bash
openclaw backup create --dry-run --json
openclaw backup create --only-config --dry-run --json
```

## Helpful context

```bash
openclaw health --json
openclaw skills list --eligible --json
openclaw plugins list --json
```

## Safe targeted config reads

These are usually safe to share:

```bash
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.redactSensitive
```

## DM / group access checks

```bash
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:

```bash
python3 "{baseDir}/scripts/redact_openclaw_config.py" ~/.openclaw/openclaw.json > openclaw.json.redacted
```

## Host / network checks

macOS:

```bash
lsof -nP -iTCP -sTCP:LISTEN
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
/usr/libexec/ApplicationFirewall/socketfilterfw --getstealthmode
fdesetup status || true
```

Linux:

```bash
ss -ltnp
sudo ufw status verbose || true
sudo nft list ruleset || true
sudo iptables -S || true
```

Docker / Compose:

```bash
docker ps --format 'table {{.Names}}	{{.Image}}	{{.Ports}}'
docker compose ps || true
docker port openclaw-gateway 18789 || true
```

## After remediation

```bash
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probe
openclaw doctor
```


path: references/openclaw-baseline-config.md
content: # 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.

```js
{
  gateway: {
    mode: "local",
    bind: "loopback",
    port: 18789,
    auth: { mode: "token", [REDACTED_SECRET]" },
  },
  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.

```js
{
  gateway: {
    mode: "local",
    bind: "loopback",
    auth: { mode: "token", [REDACTED_SECRET]" },
  },
  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.

```js
{
  gateway: {
    bind: "loopback",
    trustedProxies: ["127.0.0.1"],
    allowRealIpFallback: false,
    auth: { mode: "token", [REDACTED_SECRET]" },
    controlUi: {
      allowedOrigins: ["https://ui.example.com"],
    },
  },
}
```

Notes:

- keep `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` off
- keep `gateway.controlUi.dangerouslyDisableDeviceAuth` off
- 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 flag
- `gateway.auth.allowTailscale` can 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.allowTailscale` and require normal auth

## 6) Discovery and logging

Reduce ambient exposure:

```js
{
  discovery: {
    mdns: { mode: "minimal" }, // or "off" if unused
  },
  logging: {
    redactSensitive: true,
  },
}
```

## 7) Tool profile reminders

Current high-level profiles:

- `minimal` -> `session_status` only
- `messaging` -> message + session reply/history/status
- `coding` -> filesystem + runtime + sessions + memory + image
- `full` -> unrestricted

For user-facing inbox bots, `messaging` is usually the right starting point, then narrow further with `tools.deny`.

## Verification

After changes, re-run:

```bash
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probe
```


path: references/platform-personal-laptop.md
content: # 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

```bash
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probe
```

Confirm there is no inbound exposure beyond localhost or your intentional tailnet path.


path: references/openclaw-audit-checks.md
content: # OpenClaw 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 --fix` is useful for some filesystem/logging issues, but it will not safely solve exposure problems for you.
- For shared inboxes, treat `session.dmScope` and 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.


path: references/platform-docker.md
content: # Platform playbook: Docker / Docker Compose

## Threat assumptions

- Docker does **not** make an exposed service safe.
- If you publish `18789/tcp` to `0.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:

   ```bash
   docker ps --format 'table {{.Names}}	{{.Image}}	{{.Ports}}'
   docker compose ps || true
   docker port openclaw-gateway 18789 || true
   ```

2. **Gateway auth and bind mode**
   - even on localhost publishing, keep Gateway auth enabled
   - use `openclaw gateway probe --json` to see the effective target the CLI can reach

3. **Volume mounts**
   - identify mounts for `~/.openclaw` and 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

```yaml
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

```bash
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probe
```

External reachability tests from another machine/network should fail unless the exposure is deliberate and defended.


path: references/platform-aws-ec2.md
content: # Platform 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/tcp` from `0.0.0.0/0` or wide CIDRs
   - keep SSH tightly restricted, or disable it and use SSM

2. **Gateway exposure**
   - `gateway.bind` should normally be `loopback`
   - `openclaw gateway probe --json` should 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: false` unless 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 `~/.openclaw` and 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 (`minimal` or `off`)
- do not publish browser control or node-management surfaces publicly
- if running in Docker, also apply `platform-docker.md`

## Verification

```bash
openclaw security audit --deep --json
openclaw gateway probe --json
openclaw channels status --probe
```

Also test from an external network that port 18789 is not reachable.


path: references/platform-mac-mini.md
content: # 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:

```bash
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 || true
```

## Hardening 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 `trustedProxies` and 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 --json` shows no critical findings
- `openclaw gateway probe --json` matches the intended local/tailnet target
- listener checks show 18789 on loopback only unless there is a deliberate exception


path: scripts/collect_openclaw_audit.sh
content: #!/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}"


path: scripts/render_report.py
content: #!/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()


path: scripts/redact_openclaw_config.py
content: #!/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()


path: assets/report-template.md
content: # 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 --json` shows no critical findings
- [ ] `openclaw gateway probe --json` matches the intended listener/auth path
- [ ] `openclaw channels status --probe` shows 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.


path: assets/openclaw_checkid_map.json
content: {
  "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
  }
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)