R137/S71: single-emit audit wrappers and fail-closed connector allowlists

This commit is contained in:
rookiestar28
2026-03-01 16:31:20 +08:00
parent 6d0359a6a7
commit 807a78e76f
18 changed files with 552 additions and 92 deletions
-1
View File
@@ -18,7 +18,6 @@ coverage.xml
*.cover
reference/
AGEN*.md
roadmap.md
scripts/sync_split.py
tests_output.txt
node_modules/
+52 -11
View File
@@ -13,38 +13,68 @@ ComfyUI-OpenClaw is a **security-first orchestration layer** for ComfyUI that co
- **Now supports 7 major messaging platforms, including Discord, Telegram, WhatsApp, LINE, WeChat, KakaoTalk, and Slack.**
- **And more exciting features being added continuously**
This project is designed to make **ComfyUI a reliable automation target** with an explicit admin boundary and hardened defaults.
---
<br>
<div align="center">
<img src="assets/adminMobileConsole.png" width="70%" />
</div>
<br>
<br>
```
ComfyUI Process (single Python process + shared aiohttp app)
├── ComfyUI Core (owned by ComfyUI)
│ ├── Native routes: /prompt, /history, /view, /upload, /ws, ...
│ └── Execution engine + model runtime
└── OpenClaw package (loaded from custom_nodes/comfyui-openclaw)
├── Registers OpenClaw-managed routes into the same PromptServer app:
│ ├── /openclaw/*
│ ├── /api/openclaw/* (browser/API shim)
│ └── Legacy aliases: /moltbot/* and /api/moltbot/*
├── Security/runtime modules (startup gate, RBAC, CSRF, HMAC, audit, SSRF controls)
├── Automation services (approvals, schedules, presets, webhook/assist flows)
├── State + secrets storage (openclaw_state/*)
├── Embedded frontend extension (OpenClaw sidebar tabs) + remote admin page (/openclaw/admin)
└── ComfyUI nodes exported by this pack (planner/refiner/image-to-prompt/batch variants)
Optional companion process (outside the ComfyUI process):
└── Connector sidecar (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack) -> calls OpenClaw HTTP APIs
```
This project is designed to make **ComfyUI a reliable automation target** with an explicit admin boundary and hardened defaults.
<br>
## Security stance (how this project differs from convenience-first automation packs):
- Public profile requires explicit shared-surface boundary acknowledgement to reduce accidental exposure of ComfyUI-native high-risk routes behind reverse proxies
- Public MAE route-plane posture is guaranteed by startup enforcement plus no-skip CI route-drift checks
- Public deployments enforce Control Plane Split so high-risk controls are externalized and embedded UI stays on safer read/UX surfaces
- Runtime profile startup hardening is fail-closed in hardened mode
- Connector ingress is fail-closed in public/hardened posture when platform allowlists are missing, with synchronized startup gate, deployment-profile check, Security Doctor posture, and startup audit visibility
- Admin write actions are protected by an explicit **Admin Token** boundary
- Module capability gates prevent disabled modules from registering routes/workers
- Webhook ingress is **deny-by-default** until authentication is configured
- Encrypted webhook ingress is **fail-closed** on signature/decrypt/app-id validation failures
- Bridge worker ingress enforces device token auth, scope checks, and idempotency handling
- External tool sandboxing is fail-closed with filesystem path guards
- Outbound SSRF policy is strict for callbacks and custom LLM base URLs
- Module capability gates prevent disabled modules from registering routes/workers
- Endpoint inventory metadata plus route-drift tests catch unclassified API exposure regressions
- Pack lifecycle file paths and pack API file inputs are root-bounded and traversal-validated
- Sensitive write/admin paths use tamper-evident, append-only audit trails
- Replay risk is reduced with deterministic dedupe keys for payloads without message IDs
- Localhost-first defaults remain in place; remote access is explicit opt-in
- Localhost no-origin CSRF override posture is surfaced in startup logs, Security Doctor, and audit visibility
- Secrets are never stored in browser storage; optional server-side key store remains local-only convenience
- Secrets-at-rest encryption depends on cryptography; WeChat AES ingress stays optional via `pycryptodomex`
- Sensitive write/admin paths use tamper-evident, append-only audit trails
- External tool sandboxing is fail-closed with filesystem path guards
- Pack lifecycle file paths and pack API file inputs are root-bounded and traversal-validated
- Replay risk is reduced with deterministic dedupe keys for payloads without message IDs
- Endpoint inventory metadata plus route-drift tests catch unclassified API exposure regressions
- Retry partition hardening separates rate-limit and transport budgets with deterministic degrade decisions and lane-level diagnostics/audit evidence
- Runtime guardrails are runtime-only, with diagnostics, clamping, and reject-on-persist behavior for safety-critical limits
- Compatibility matrix freshness/drift governance is surfaced in Doctor with repeatable refresh evidence
- Management queries enforce deterministic pagination normalization and bounded scans against malformed or unbounded admin/list requests
- Cryptographic lifecycle drills emit machine-readable evidence for rotate/revoke/key-loss/token-compromise fail-closed exercises
- Runtime guardrails are runtime-only, with diagnostics, clamping, and reject-on-persist behavior for safety-critical limits
- Management queries enforce deterministic pagination normalization and bounded scans against malformed or unbounded admin/list requests
- Retry partition hardening separates rate-limit and transport budgets with deterministic degrade decisions and lane-level diagnostics/audit evidence
- Compatibility matrix freshness/drift governance is surfaced in Doctor with repeatable refresh evidence
- Adversarial verification gates (bounded fuzz + mutation smoke) are enforced in CI and local full-test/pre-push workflows
- Wave E hardening closeout includes deployment-profile gates, critical-flow parity, signed policy posture controls, bounded anomaly telemetry, adversarial fuzz validation, and mutation sensitivity checks
- Wave A/B/C hardening closeout includes runtime/config/session stability contracts, strict outbound and supply-chain controls, and capability-aware operator guidance with bounded Parameter Lab/compare workflows
@@ -61,6 +91,17 @@ Deployment profiles and hardening checklists:
<details>
<summary><strong>Audit event clarity and connector ingress fail-closed hardening</strong></summary>
- Normalized audit helper behavior so config/secret/LLM-test convenience wrappers now emit one canonical audit event per action, reducing duplicate noise while preserving legacy compatibility paths.
- Added shared connector allowlist posture evaluation and enforced fail-closed startup behavior for public/hardened deployments when connector ingress is active without allowlist coverage.
- Kept local/permissive posture as warning-only, with synchronized visibility across startup checks, deployment profile checks, and Security Doctor diagnostics.
- Added focused regression coverage and completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Startup fail-closed bootstrap hardening and public boundary guardrail</strong></summary>
- Enforced strict fail-closed startup propagation so bootstrap security-gate failures are no longer logged-and-continued; route/worker registration now aborts deterministically on fatal startup failures.
+2 -1
View File
@@ -18,7 +18,8 @@ If the deployment enables remote control or bridge features, it must also pass *
- [ ] **Admin Boundaries**:
- [ ] Server-side admin write boundary uses `OPENCLAW_ADMIN_TOKEN` (legacy `MOLTBOT_ADMIN_TOKEN`).
- [ ] Connector admin command paths use `OPENCLAW_CONNECTOR_ADMIN_TOKEN`, and must match server admin token when server admin auth is enabled.
- [ ] **Webhooks**: Listening webhooks (Discord/Line/Telegram) are disabled unless their respective tokens are configured (`OPENCLAW_CONNECTOR_DISCORD_TOKEN`, etc.).
- [ ] **Connector Ingress Defaults**: Platform adapters remain disabled unless required token/enable vars are configured (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack).
- [ ] **Connector Allowlists (Strict Posture)**: In `public` deployment or `hardened` runtime posture, active connector platforms must have allowlist coverage before startup (fail-closed; public check code `DP-PUBLIC-009`).
- [ ] **Observability**: `/openclaw/logs/tail` and `/openclaw/config` require `OPENCLAW_OBSERVABILITY_TOKEN` (legacy: `MOLTBOT_OBSERVABILITY_TOKEN`) if accessed remotely, or are loopback-only.
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist.
- [ ] **Public Boundary Contract (S69)**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL deny ComfyUI-native high-risk routes.
+17 -4
View File
@@ -97,7 +97,19 @@ export OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1
If this acknowledgement is missing in public profile, deployment profile checks fail with `DP-PUBLIC-008`.
### 4. Startup Gate Behavior (R136 + S56)
### 4. Connector Allowlist Fail-Closed (Public/Hardened)
Connector ingress posture is fail-closed in strict profiles:
- if connector platform ingress is active (Telegram/Discord/LINE/WhatsApp/WeChat/Kakao/Slack)
- and matching allowlist variables are missing
- startup/deployment checks fail closed (`DP-PUBLIC-009` for public profile)
Operational requirement:
- never enable connector platform tokens/enable flags in public or hardened posture without platform allowlist coverage.
### 5. Startup Gate Behavior (R136 + S56)
Startup security gates are fail-closed. Fatal startup gate/bootstrap failures abort route/worker registration and do not continue in a partial state.
@@ -107,7 +119,7 @@ Recommended preflight:
python scripts/check_deployment_profile.py --profile public --strict-warnings
```
### 5. SSRF Protection
### 6. SSRF Protection
OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request Forgery (SSRF).
@@ -126,7 +138,7 @@ OpenClaw validates custom LLM `base_url` settings to prevent Server-Side Request
```
- avoid broad bypass flags in production (`OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST`, `OPENCLAW_ALLOW_INSECURE_BASE_URL`).
### 6. Rate Limiting
### 7. Rate Limiting
OpenClaw enforces internal rate limits:
@@ -134,7 +146,7 @@ OpenClaw enforces internal rate limits:
* Logs: 60/min
* Admin: 20/min
### 7. Sidecar Bridge
### 8. Sidecar Bridge
OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bots (Discord/Slack).
@@ -152,6 +164,7 @@ OpenClaw supports a "Sidecar Bridge" (F10) for safe interaction with external bo
* [ ] **Trusted proxy config**: set `OPENCLAW_TRUST_X_FORWARDED_FOR=1` and exact `OPENCLAW_TRUSTED_PROXIES`.
* [ ] **Public shared-surface ack**: for `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after proxy path allowlist + ACL are verified.
* [ ] **Public path deny rules**: block ComfyUI-native high-risk routes and `/api/*` equivalents unless explicitly required.
* [ ] **Connector strict-posture allowlists**: if connector ingress is active in `public` or `hardened`, ensure platform allowlists are set before startup (`DP-PUBLIC-009` for public profile).
* [ ] **Startup gate preflight**: run `python scripts/check_deployment_profile.py --profile public --strict-warnings`.
* [ ] **Runtime diagnostics**: review `GET /openclaw/security/doctor` before exposure.
* [ ] **Least privilege host posture**: do not run as root/Administrator.
+1
View File
@@ -15,6 +15,7 @@ The connector runs alongside ComfyUI on your machine.
- **Transport Model**: Telegram/Discord are outbound. LINE/WhatsApp/WeChat/KakaoTalk/Slack require inbound HTTPS webhook endpoints.
- **Allowlist/Trust Model**: Allowlists define trusted senders/channels. Non-allowlisted senders are treated as untrusted (for example, `/run` is approval-routed instead of auto-executed).
- **Strict Profile Gate**: In `public` deployment or `hardened` runtime posture, enabling connector ingress without platform allowlist coverage is fail-closed at startup/deployment checks.
- **Local Secrets**: Bot tokens are stored in your local environment, never sent to ComfyUI.
- **Admin Boundary**: Control-plane actions call admin endpoints on the local OpenClaw server and require connector-side admin token configuration for admin command paths.
+24 -2
View File
@@ -1,8 +1,8 @@
# OpenClaw Config & Secrets Contract (v1)
> **Status**: normative
> **Version**: 1.0.2
> **Date**: 2026-02-26
> **Version**: 1.0.3
> **Date**: 2026-03-01
This document defines the authoritative configuration contract for OpenClaw. It enumerates all supported environment variables, their precedence rules, and security classifications.
@@ -69,11 +69,33 @@ Controls the `connector` sidecar process and outbound delivery.
| `OPENCLAW_CONNECTOR_URL` | Core | URL of the OpenClaw backend (default: `http://127.0.0.1:8188`). |
| `OPENCLAW_CONNECTOR_ADMIN_TOKEN` | Core | Token to authenticate Connector calls to Backend. |
| `OPENCLAW_CONNECTOR_TELEGRAM_TOKEN` | Telegram | Bot API Token. |
| `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS` | Telegram | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS`| Telegram | Comma-separated allowlist of logic IDs (User IDs or Chat IDs). |
| `OPENCLAW_CONNECTOR_DISCORD_TOKEN` | Discord | Bot User Token. |
| `OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS` | Discord | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS`| Discord | Comma-separated list of Channel IDs. |
| `OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET` | LINE | Channel Secret. |
| `OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN`| LINE | Channel Access Token. |
| `OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS` | LINE | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_LINE_ALLOWED_GROUPS` | LINE | Comma-separated trusted group IDs. |
| `OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN` | WhatsApp | Cloud API access token. |
| `OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET` | WhatsApp | App secret for webhook signature validation. |
| `OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS` | WhatsApp | Comma-separated trusted `wa_id` values. |
| `OPENCLAW_CONNECTOR_WECHAT_TOKEN` | WeChat | Webhook verification token. |
| `OPENCLAW_CONNECTOR_WECHAT_APP_ID` | WeChat | Official Account AppID. |
| `OPENCLAW_CONNECTOR_WECHAT_APP_SECRET` | WeChat | Official Account AppSecret. |
| `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS` | WeChat | Comma-separated trusted OpenID values. |
| `OPENCLAW_CONNECTOR_KAKAO_ENABLED` | KakaoTalk | Enables Kakao adapter when truthy. |
| `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS` | KakaoTalk | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN` | Slack | Bot OAuth token (`xoxb-*`). |
| `OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET` | Slack | Ingress signature secret. |
| `OPENCLAW_CONNECTOR_SLACK_APP_TOKEN` | Slack | Optional Socket Mode app token (`xapp-*`). |
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` | Slack | Comma-separated trusted user IDs. |
| `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` | Slack | Comma-separated trusted channel IDs. |
Connector posture rules:
- In strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), active connector platforms without allowlist coverage are fail-closed.
- Public deployment profile check surfaces this as `DP-PUBLIC-009`.
**Delivery & Media:**
+16 -1
View File
@@ -37,8 +37,11 @@ Current mandatory checks:
- `OPENCLAW_ALLOW_INSECURE_BASE_URL` must not bypass policy
- If webhook module is active, webhook auth mode must be configured
- Redaction service must be available
- Connector ingress allowlist coverage is enforced for strict posture:
- `OPENCLAW_RUNTIME_PROFILE=hardened`: startup fails closed if an active connector platform has no allowlist
- `OPENCLAW_DEPLOYMENT_PROFILE=public`: deployment/startup checks fail closed for the same condition (`DP-PUBLIC-009`)
In `minimal` mode, the same checks emit warnings but do not block startup.
In `minimal` mode, these checks are warning-first for local/LAN posture, but `public` deployment profile still enforces fail-closed policy checks.
### Bootstrap fail-closed propagation
@@ -63,6 +66,18 @@ Gate behavior:
- missing ack in public profile: deployment profile check fails (`DP-PUBLIC-008`)
- ack present: this boundary contract check passes
## Connector allowlist posture in strict profiles
Connector token/enable markers activate ingress posture checks for:
- Telegram, Discord, LINE, WhatsApp, WeChat, KakaoTalk, Slack
When a platform is active, at least one platform-specific allowlist variable must be configured.
- `public` deployment profile: fail-closed (`DP-PUBLIC-009`)
- `hardened` runtime profile: fail-closed at startup gate
- non-strict local/LAN posture: warning posture in Security Doctor (`s32_allowlist_coverage`)
## Localhost no-origin override posture
`OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN` controls a localhost convenience escape hatch for clients
+7
View File
@@ -11,7 +11,12 @@
- Telegram: `OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS` / `_ALLOWED_CHATS`
- Discord: `OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS` / `_ALLOWED_CHANNELS`
- LINE: `OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS` / `_ALLOWED_GROUPS`
- WhatsApp: `OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS`
- WeChat: `OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS`
- KakaoTalk: `OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS`
- Slack: `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` / `_ALLOWED_CHANNELS`
- [ ] Verify startup banner shows "No trusted users" warning if allowlists are empty.
- [ ] For strict posture (`OPENCLAW_DEPLOYMENT_PROFILE=public` or `OPENCLAW_RUNTIME_PROFILE=hardened`), do not enable connector ingress without allowlists; startup/deployment checks fail closed.
### 2. Webhook Security (LINE)
@@ -36,6 +41,7 @@
- [ ] Never expose admin endpoints without token.
- [ ] For shared/LAN/public exposure, keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` (or unset).
- [ ] For `OPENCLAW_DEPLOYMENT_PROFILE=public`, set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after reverse-proxy path allowlist + network ACL explicitly block ComfyUI-native high-risk routes.
- [ ] For `OPENCLAW_DEPLOYMENT_PROFILE=public`, if any connector platform token/enable flag is set, confirm corresponding allowlist coverage before startup (`DP-PUBLIC-009`).
- [ ] Run `GET /openclaw/security/doctor` and verify no `csrf_no_origin_override` warning before exposure.
### 6. Debug Mode
@@ -55,6 +61,7 @@
| Feature | Default | Effect |
|---------|---------|--------|
| Empty allowlists | Untrusted | All `/run` requires approval |
| Active connector without allowlist in strict posture (`public`/`hardened`) | Fail-closed | Startup/deployment checks block serving |
| No admin users | Limited | Admin commands unavailable |
| Rate limiting | Enabled | 10 req/min/user, 30 req/min/channel |
| Debug mode | Disabled | No sensitive logging |
+14 -7
View File
@@ -50,7 +50,7 @@ Notes:
|---|---|---|
| `local` | single operator on same machine | no remote admin, no proxy trust, high-risk features disabled unless explicitly needed |
| `lan` | trusted private network | admin + observability token, webhook auth + replay protection, remote admin opt-in, risky features off |
| `public` | internet-facing reverse proxy | strict token boundaries, trusted proxy config, remote admin off, control-plane split required, risky features off, webhook auth fail-closed |
| `public` | internet-facing reverse proxy | strict token boundaries, trusted proxy config, remote admin off, control-plane split required, risky features off, webhook auth fail-closed, connector allowlist fail-closed |
## 2. Self-check Command
@@ -191,6 +191,12 @@ OPENCLAW_WEBHOOK_REQUIRE_REPLAY_PROTECTION=1
# Optional callback path must be tightly allowlisted
OPENCLAW_CALLBACK_ALLOW_HOSTS=example.com,api.example.com
# If connector ingress is enabled in public posture, set platform allowlists.
# Otherwise startup/deployment checks fail closed.
# Example (Telegram):
# OPENCLAW_CONNECTOR_TELEGRAM_TOKEN=...
# OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS=123456
# Keep risky expansion surfaces off on public user plane
OPENCLAW_ENABLE_EXTERNAL_TOOLS=0
OPENCLAW_ENABLE_REGISTRY_SYNC=0
@@ -212,16 +218,17 @@ OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0
- deny ComfyUI-native high-risk paths (`/prompt`, `/history*`, `/view*`, `/upload*`, `/ws`) and `/api/*` equivalents.
4. Set `OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK=1` only after step 3 and network ACL hardening are in place.
5. Enforce split control plane in public posture (`OPENCLAW_CONTROL_PLANE_MODE=split` + external URL/TOKEN).
6. Keep risky features disabled on public user-facing plane.
7. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` in public deployments.
8. Verify split posture from capabilities:
6. If any connector platform token/enable flag is configured, set corresponding platform allowlist vars before startup (`DP-PUBLIC-009` fail-closed).
7. Keep risky features disabled on public user-facing plane.
8. Keep `OPENCLAW_LOCALHOST_ALLOW_NO_ORIGIN=0` in public deployments.
9. Verify split posture from capabilities:
- `GET /openclaw/capabilities` and confirm `control_plane.mode=split`
9. Run:
10. Run:
- `python scripts/check_deployment_profile.py --profile public`
10. Validate with project test and release gates before rollout:
11. Validate with project test and release gates before rollout:
- `tests/TEST_SOP.md`
- [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md)
11. Ensure `/openclaw/admin` is blocked at public edge unless a separately hardened private admin plane is in place.
12. Ensure `/openclaw/admin` is blocked at public edge unless a separately hardened private admin plane is in place.
## 6. Bridge in Public Profile (only when absolutely required)
+1 -8
View File
@@ -334,7 +334,7 @@ def emit_audit_event(*args, **kwargs) -> Dict[str, Any]:
def audit_config_write(actor_ip: str, ok: bool, error: Optional[str] = None) -> None:
emit_audit_event("settings.config_write", actor_ip, ok, error=error)
# CRITICAL: keep convenience wrappers single-emit to avoid duplicate audit noise.
emit_audit_event(
action="config.update",
target="config.json",
@@ -349,9 +349,6 @@ def audit_config_write(actor_ip: str, ok: bool, error: Optional[str] = None) ->
def audit_secret_write(
actor_ip: str, provider: str, ok: bool, error: Optional[str] = None
) -> None:
emit_audit_event(
"settings.secret_write", actor_ip, ok, provider=provider, error=error
)
emit_audit_event(
action="secrets.write",
target=provider,
@@ -368,9 +365,6 @@ def audit_secret_write(
def audit_secret_delete(
actor_ip: str, provider: str, ok: bool, error: Optional[str] = None
) -> None:
emit_audit_event(
"settings.secret_delete", actor_ip, ok, provider=provider, error=error
)
emit_audit_event(
action="secrets.delete",
target=provider,
@@ -385,7 +379,6 @@ def audit_secret_delete(
def audit_llm_test(actor_ip: str, ok: bool, error: Optional[str] = None) -> None:
emit_audit_event("settings.llm_test", actor_ip, ok, error=error)
emit_audit_event(
action="llm.test_connection",
target="llm",
+160
View File
@@ -0,0 +1,160 @@
"""
Connector allowlist posture helpers (S71).
Shared by startup/security checks so fail-closed and diagnostics stay aligned.
"""
from __future__ import annotations
import os
from typing import Any, Dict, Mapping, Optional
TRUTHY = {"1", "true", "yes", "on"}
# IMPORTANT: keep these platform rules in sync with connector/config.py env contract.
# Drift here can silently weaken fail-closed posture in hardened/public deployments.
_CONNECTOR_PLATFORM_RULES: Dict[str, Dict[str, list[str]]] = {
"telegram": {
"active_value_vars": ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"],
"active_truthy_vars": [],
"allowlist_vars": [
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS",
],
},
"discord": {
"active_value_vars": ["OPENCLAW_CONNECTOR_DISCORD_TOKEN"],
"active_truthy_vars": [],
"allowlist_vars": [
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS",
],
},
"line": {
"active_value_vars": [
"OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN",
],
"active_truthy_vars": [],
"allowlist_vars": [
"OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_LINE_ALLOWED_GROUPS",
],
},
"whatsapp": {
"active_value_vars": [
"OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET",
],
"active_truthy_vars": [],
"allowlist_vars": ["OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS"],
},
"wechat": {
"active_value_vars": [
"OPENCLAW_CONNECTOR_WECHAT_TOKEN",
"OPENCLAW_CONNECTOR_WECHAT_APP_ID",
"OPENCLAW_CONNECTOR_WECHAT_APP_SECRET",
],
"active_truthy_vars": [],
"allowlist_vars": ["OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"],
},
"kakao": {
"active_value_vars": [],
"active_truthy_vars": ["OPENCLAW_CONNECTOR_KAKAO_ENABLED"],
"allowlist_vars": ["OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS"],
},
"slack": {
"active_value_vars": [
"OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN",
"OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET",
"OPENCLAW_CONNECTOR_SLACK_APP_TOKEN",
],
"active_truthy_vars": [],
"allowlist_vars": [
"OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS",
],
},
}
def _has_value(env: Mapping[str, str], key: str) -> bool:
return bool((env.get(key) or "").strip())
def _is_truthy(env: Mapping[str, str], key: str) -> bool:
return (env.get(key) or "").strip().lower() in TRUTHY
def _dedupe_keep_order(values: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
out.append(value)
return out
def evaluate_connector_allowlist_posture(
environ: Optional[Mapping[str, str]] = None,
) -> Dict[str, Any]:
env: Mapping[str, str] = environ or os.environ
active_platforms: list[str] = []
unguarded_platforms: list[str] = []
active_markers: list[str] = []
configured_allowlists: list[str] = []
recommended_allowlist_vars: list[str] = []
for platform, rules in _CONNECTOR_PLATFORM_RULES.items():
allowlist_vars = rules["allowlist_vars"]
recommended_allowlist_vars.extend(allowlist_vars)
platform_active = False
for var in rules["active_value_vars"]:
if _has_value(env, var):
platform_active = True
active_markers.append(var)
for var in rules["active_truthy_vars"]:
if _is_truthy(env, var):
platform_active = True
active_markers.append(var)
if not platform_active:
continue
active_platforms.append(platform)
platform_allowlisted = False
for var in allowlist_vars:
if _has_value(env, var):
platform_allowlisted = True
configured_allowlists.append(var)
if not platform_allowlisted:
unguarded_platforms.append(platform)
active_platforms = _dedupe_keep_order(active_platforms)
unguarded_platforms = _dedupe_keep_order(unguarded_platforms)
active_markers = _dedupe_keep_order(active_markers)
configured_allowlists = _dedupe_keep_order(configured_allowlists)
recommended_allowlist_vars = _dedupe_keep_order(recommended_allowlist_vars)
return {
"active_platforms": active_platforms,
"unguarded_platforms": unguarded_platforms,
"active_markers": active_markers,
"configured_allowlists": configured_allowlists,
"recommended_allowlist_vars": recommended_allowlist_vars,
"has_active_connectors": bool(active_platforms),
"has_unguarded_connectors": bool(unguarded_platforms),
}
def is_strict_connector_allowlist_profile(
environ: Optional[Mapping[str, str]] = None,
) -> bool:
env: Mapping[str, str] = environ or os.environ
deployment_profile = (env.get("OPENCLAW_DEPLOYMENT_PROFILE") or "").strip().lower()
runtime_profile = (env.get("OPENCLAW_RUNTIME_PROFILE") or "").strip().lower()
return deployment_profile == "public" or runtime_profile == "hardened"
+35
View File
@@ -13,6 +13,13 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Iterable, Mapping, Optional
try:
from .connector_allowlist_posture import evaluate_connector_allowlist_posture
except Exception:
from services.connector_allowlist_posture import ( # type: ignore
evaluate_connector_allowlist_posture,
)
TRUTHY = {"1", "true", "yes", "on"}
FALSY = {"0", "false", "no", "off"}
_PUBLIC_BOUNDARY_ACK_ENV = "OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK"
@@ -463,6 +470,34 @@ def evaluate_deployment_profile(
"If callback delivery is used, set OPENCLAW_CALLBACK_ALLOW_HOSTS to strict host allowlist.",
)
connector_posture = evaluate_connector_allowlist_posture(env)
if connector_posture["has_unguarded_connectors"]:
# CRITICAL: public profile must fail closed for connector ingress without
# explicit allowlists; warn-only here would leave internet-facing gaps.
report.add(
"fail",
"DP-PUBLIC-009",
"Connector allowlist coverage is missing for active platform(s): "
+ ", ".join(connector_posture["unguarded_platforms"]),
(
"Set connector allowlist vars before enabling public deployment. "
"Allowed vars: "
+ ", ".join(connector_posture["recommended_allowlist_vars"])
),
)
elif connector_posture["has_active_connectors"]:
report.add(
"pass",
"DP-PUBLIC-009",
"Active connector platforms have allowlist coverage.",
)
else:
report.add(
"pass",
"DP-PUBLIC-009",
"No connector ingress platforms are active.",
)
if bridge_enabled:
if not _is_enabled(env, "OPENCLAW_BRIDGE_MTLS_ENABLED"):
report.add(
+59 -50
View File
@@ -35,6 +35,17 @@ from typing import Any, Dict, List, Optional, Set, Tuple
logger = logging.getLogger("ComfyUI-OpenClaw.services.security_doctor")
try:
from .connector_allowlist_posture import (
evaluate_connector_allowlist_posture,
is_strict_connector_allowlist_profile,
)
except Exception:
from services.connector_allowlist_posture import ( # type: ignore
evaluate_connector_allowlist_posture,
is_strict_connector_allowlist_profile,
)
# ---------------------------------------------------------------------------
# WP1: S30 Violation Code Mapping Table (bounded vocabulary)
# ---------------------------------------------------------------------------
@@ -866,50 +877,30 @@ def check_api_key_posture(report: SecurityReport) -> None:
# Security checks — S32 Connector security posture
# ---------------------------------------------------------------------------
# Environment variables checked by S32
_S32_CONNECTOR_TOKEN_VARS = [
"OPENCLAW_CONNECTOR_ADMIN_TOKEN",
"OPENCLAW_CONNECTOR_TELEGRAM_TOKEN",
"OPENCLAW_CONNECTOR_DISCORD_TOKEN",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET",
"OPENCLAW_CONNECTOR_WECHAT_TOKEN",
"OPENCLAW_CONNECTOR_WECHAT_APP_ID",
"OPENCLAW_CONNECTOR_WECHAT_APP_SECRET",
]
_S32_ALLOWLIST_VARS = [
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS",
"OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS",
]
def check_connector_security_posture(report: SecurityReport) -> None:
"""S32: Check connector security posture for internet-exposed deployments."""
# --- Token presence ---
active_tokens = []
missing_tokens = []
for var in _S32_CONNECTOR_TOKEN_VARS:
val = os.environ.get(var, "").strip()
if val:
active_tokens.append(var)
else:
missing_tokens.append(var)
posture = evaluate_connector_allowlist_posture(os.environ)
active_markers = posture["active_markers"]
active_platforms = posture["active_platforms"]
unguarded_platforms = posture["unguarded_platforms"]
configured_allowlists = posture["configured_allowlists"]
recommended_allowlist_vars = posture["recommended_allowlist_vars"]
if active_tokens:
# --- Connector activation presence ---
if active_markers:
report.add(
SecurityCheckResult(
name="s32_connector_tokens",
severity=SecuritySeverity.PASS.value,
message=f"{len(active_tokens)} connector token(s) configured",
message=f"{len(active_platforms)} connector platform(s) active",
category="connector",
detail="Active: " + ", ".join(active_tokens),
detail=(
"Platforms: "
+ ", ".join(active_platforms)
+ " | markers: "
+ ", ".join(active_markers)
),
)
)
else:
@@ -923,31 +914,49 @@ def check_connector_security_posture(report: SecurityReport) -> None:
)
# --- Allowlist coverage ---
configured_allowlists = []
for var in _S32_ALLOWLIST_VARS:
val = os.environ.get(var, "").strip()
if val:
configured_allowlists.append(var)
# Only warn if tokens are configured but allowlists are empty
if active_tokens and not configured_allowlists:
# CRITICAL: keep strict-profile escalation aligned with startup fail-closed.
# If this drifts, doctor may show WARN while startup hard-fails (or vice versa).
if active_platforms and unguarded_platforms:
strict_profile = is_strict_connector_allowlist_profile(os.environ)
severity = (
SecuritySeverity.FAIL.value
if strict_profile
else SecuritySeverity.WARN.value
)
posture_hint = "public/hardened" if strict_profile else "non-strict"
report.add(
SecurityCheckResult(
name="s32_allowlist_coverage",
severity=SecuritySeverity.WARN.value,
message="Connector tokens active but no user/channel allowlists configured",
severity=severity,
message=(
"Connector ingress active but allowlist coverage missing for: "
+ ", ".join(unguarded_platforms)
),
category="connector",
detail="Without allowlists, connectors may accept messages from any user.",
remediation=("Set at least one of: " + ", ".join(_S32_ALLOWLIST_VARS)),
detail=(
f"Profile posture={posture_hint}. "
"Without allowlists, connectors may accept messages from any user/channel."
),
remediation=(
"Set platform allowlists before enabling internet-facing connector ingress. "
"Allowed vars: " + ", ".join(recommended_allowlist_vars)
),
)
)
elif configured_allowlists:
elif active_platforms:
report.add(
SecurityCheckResult(
name="s32_allowlist_coverage",
severity=SecuritySeverity.PASS.value,
message=f"{len(configured_allowlists)} connector allowlist(s) configured",
message=(
f"Allowlist coverage present for {len(active_platforms)} active connector platform(s)"
),
category="connector",
detail=(
"Configured allowlists: " + ", ".join(configured_allowlists)
if configured_allowlists
else "Configured allowlists: (none required for inactive platforms)"
),
)
)
@@ -982,7 +991,7 @@ def check_connector_security_posture(report: SecurityReport) -> None:
# --- DM policy open warning ---
dev_mode = os.environ.get("MOLTBOT_DEV_MODE", "").strip().lower()
if dev_mode in ("1", "true", "yes", "on") and active_tokens:
if dev_mode in ("1", "true", "yes", "on") and active_markers:
report.add(
SecurityCheckResult(
name="s32_dev_mode_with_connectors",
+54
View File
@@ -11,6 +11,13 @@ from typing import List, Tuple
from .runtime_profile import get_runtime_profile, is_hardened_mode
try:
from .connector_allowlist_posture import evaluate_connector_allowlist_posture
except Exception:
from services.connector_allowlist_posture import ( # type: ignore
evaluate_connector_allowlist_posture,
)
logger = logging.getLogger(__name__)
@@ -208,6 +215,53 @@ class SecurityGate:
except ImportError:
warnings.append("S62 control_plane module failed to import")
# 7. Connector allowlist fail-closed posture (S71)
connector_posture = evaluate_connector_allowlist_posture(os.environ)
if connector_posture["has_unguarded_connectors"]:
deployment_profile = (
os.environ.get("OPENCLAW_DEPLOYMENT_PROFILE", "").strip().lower()
)
platforms = ", ".join(connector_posture["unguarded_platforms"])
allowlist_vars = ", ".join(connector_posture["recommended_allowlist_vars"])
msg = (
"Connector allowlist coverage missing for active platform(s): "
f"{platforms}. Configure allowlists ({allowlist_vars}) before enabling ingress."
)
# CRITICAL: hardened/public must fail closed for unallowlisted connector ingress.
if is_hardened_mode():
warnings.append(f"S71 (hardened fail-closed): {msg}")
_emit_startup_audit(
action="startup.connector_allowlist_posture",
outcome="error",
details={
"mode": "hardened",
"unguarded_platforms": connector_posture["unguarded_platforms"],
"deployment_profile": deployment_profile or "unset",
},
)
elif deployment_profile == "public":
fatal_errors.append(f"S71 (public fail-closed): {msg}")
_emit_startup_audit(
action="startup.connector_allowlist_posture",
outcome="error",
details={
"mode": "public",
"unguarded_platforms": connector_posture["unguarded_platforms"],
},
)
else:
warnings.append(f"S71 (warn-only): {msg}")
_emit_startup_audit(
action="startup.connector_allowlist_posture",
outcome="warn",
details={
"mode": "warn_only",
"unguarded_platforms": connector_posture["unguarded_platforms"],
"deployment_profile": deployment_profile or "unset",
},
)
# In HARDENED mode, treat all warnings as FATAL
if is_hardened_mode() and warnings:
fatal_errors.extend(warnings)
+2 -5
View File
@@ -78,21 +78,18 @@ class TestAudit(unittest.TestCase):
self.assertEqual(entries[0]["prev_hash"], "GENESIS")
self.assertEqual(entries[1]["prev_hash"], entries[0]["entry_hash"])
def test_legacy_shims(self):
def test_legacy_shims_single_emit_contract(self):
audit_config_write("1.2.3.4", ok=True)
audit_llm_test("1.2.3.4", ok=False, error="bad")
audit_secret_write("1.2.3.4", "openai", ok=True)
audit_secret_delete("1.2.3.4", "openai", ok=False, error="not_found")
entries = self._read_entries()
self.assertEqual(len(entries), 4)
actions = [e["action"] for e in entries]
self.assertIn("settings.config_write", actions)
self.assertIn("config.update", actions)
self.assertIn("settings.llm_test", actions)
self.assertIn("llm.test_connection", actions)
self.assertIn("settings.secret_write", actions)
self.assertIn("secrets.write", actions)
self.assertIn("settings.secret_delete", actions)
self.assertIn("secrets.delete", actions)
def test_write_path_uses_atomic_lock(self):
+41 -2
View File
@@ -543,6 +543,8 @@ class TestSecurityDoctorConnectorPosture(unittest.TestCase):
# Save and clear all S32-relevant env vars
self._saved_env = {}
s32_vars = [
"OPENCLAW_DEPLOYMENT_PROFILE",
"OPENCLAW_RUNTIME_PROFILE",
"OPENCLAW_CONNECTOR_ADMIN_TOKEN",
"OPENCLAW_CONNECTOR_TELEGRAM_TOKEN",
"OPENCLAW_CONNECTOR_DISCORD_TOKEN",
@@ -550,11 +552,24 @@ class TestSecurityDoctorConnectorPosture(unittest.TestCase):
"OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET",
"OPENCLAW_CONNECTOR_WECHAT_TOKEN",
"OPENCLAW_CONNECTOR_WECHAT_APP_ID",
"OPENCLAW_CONNECTOR_WECHAT_APP_SECRET",
"OPENCLAW_CONNECTOR_KAKAO_ENABLED",
"OPENCLAW_CONNECTOR_SLACK_BOT_TOKEN",
"OPENCLAW_CONNECTOR_SLACK_SIGNING_SECRET",
"OPENCLAW_CONNECTOR_SLACK_APP_TOKEN",
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_CHATS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS",
"OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_LINE_ALLOWED_GROUPS",
"OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS",
"MOLTBOT_DEV_MODE",
]
for var in s32_vars:
@@ -593,8 +608,8 @@ class TestSecurityDoctorConnectorPosture(unittest.TestCase):
self.assertEqual(token_check.severity, self.SecuritySeverity.PASS.value)
self.assertEqual(allowlist_check.severity, self.SecuritySeverity.PASS.value)
def test_tokens_without_allowlist_warns(self):
"""Tokens active but no allowlist → WARN."""
def test_tokens_without_allowlist_warns_in_non_strict_profile(self):
"""Tokens active but no allowlist → WARN for non-public/non-hardened posture."""
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "test-tok"
report = self.SecurityReport()
self.check(report)
@@ -606,6 +621,30 @@ class TestSecurityDoctorConnectorPosture(unittest.TestCase):
len(allowlist_check[0].remediation) > 0, "Should have remediation text"
)
def test_tokens_without_allowlist_fail_in_public_profile(self):
"""Tokens active but no allowlist → FAIL for public deployment posture."""
os.environ["OPENCLAW_DEPLOYMENT_PROFILE"] = "public"
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "test-tok"
report = self.SecurityReport()
self.check(report)
allowlist_check = [
c for c in report.checks if c.name == "s32_allowlist_coverage"
]
self.assertEqual(len(allowlist_check), 1)
self.assertEqual(allowlist_check[0].severity, self.SecuritySeverity.FAIL.value)
def test_tokens_without_allowlist_fail_in_hardened_runtime(self):
"""Tokens active but no allowlist → FAIL for hardened runtime posture."""
os.environ["OPENCLAW_RUNTIME_PROFILE"] = "hardened"
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "test-tok"
report = self.SecurityReport()
self.check(report)
allowlist_check = [
c for c in report.checks if c.name == "s32_allowlist_coverage"
]
self.assertEqual(len(allowlist_check), 1)
self.assertEqual(allowlist_check[0].severity, self.SecuritySeverity.FAIL.value)
def test_whatsapp_token_without_secret_warns(self):
"""WhatsApp access token set without app_secret → WARN."""
os.environ["OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN"] = "wa-tok"
+26
View File
@@ -149,6 +149,32 @@ class TestEvaluateStartupGate(unittest.TestCase):
codes = {v["code"] for v in result.violations}
self.assertIn("DP-PUBLIC-008", codes)
def test_public_connector_token_without_allowlist_fails(self):
"""S71: public profile fails closed when connector ingress lacks allowlist."""
env = {
"OPENCLAW_DEPLOYMENT_PROFILE": "public",
"OPENCLAW_ADMIN_TOKEN": "admin-token",
"OPENCLAW_OBSERVABILITY_TOKEN": "obs-token",
"OPENCLAW_ALLOW_REMOTE_ADMIN": "0",
"OPENCLAW_PUBLIC_SHARED_SURFACE_BOUNDARY_ACK": "1",
"OPENCLAW_TRUST_X_FORWARDED_FOR": "1",
"OPENCLAW_TRUSTED_PROXIES": "127.0.0.1",
"OPENCLAW_WEBHOOK_AUTH_MODE": "hmac",
"OPENCLAW_WEBHOOK_HMAC_SECRET": "secret",
"OPENCLAW_WEBHOOK_REQUIRE_REPLAY_PROTECTION": "1",
"OPENCLAW_ENABLE_EXTERNAL_TOOLS": "0",
"OPENCLAW_ENABLE_REGISTRY_SYNC": "0",
"OPENCLAW_ENABLE_TRANSFORMS": "0",
"OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST": "0",
"OPENCLAW_ALLOW_INSECURE_BASE_URL": "0",
"OPENCLAW_SECURITY_DANGEROUS_BIND_OVERRIDE": "0",
"OPENCLAW_CONNECTOR_TELEGRAM_TOKEN": "tok",
}
result = evaluate_startup_gate(env)
self.assertFalse(result.passed)
codes = {v["code"] for v in result.violations}
self.assertIn("DP-PUBLIC-009", codes)
# ---- override contract ----
def test_override_bypasses_failing_gate(self):
+41
View File
@@ -146,6 +146,47 @@ class TestSecurityGate(unittest.TestCase):
"Expected startup warning for no-origin override",
)
@patch.dict(os.environ, {"OPENCLAW_CONNECTOR_TELEGRAM_TOKEN": "tok"}, clear=False)
@patch("services.security_gate.is_hardened_mode", return_value=True)
@patch("services.access_control.is_auth_configured", return_value=True)
@patch("services.access_control.is_any_token_configured", return_value=True)
@patch("services.runtime_config.get_config")
@patch("services.modules.is_module_enabled", return_value=False)
@patch("services.tool_runner.is_tools_enabled", return_value=False)
@patch(
"services.permission_posture.evaluate_startup_permissions",
return_value=(True, []),
)
@patch(
"services.control_plane.enforce_control_plane_startup",
return_value={"startup_passed": True, "errors": [], "warnings": []},
)
def test_hardened_connector_without_allowlist_fails_closed(
self,
_mock_cp,
_mock_perms,
_mock_tools,
_mock_mod_enabled,
mock_get_config,
_mock_any_auth,
_mock_auth,
_mock_hardened,
):
"""S71: hardened runtime must fail when connector ingress has no allowlist."""
cfg = MagicMock()
cfg.allow_any_public_llm_host = False
cfg.allow_insecure_base_url = False
cfg.webhook_auth_mode = "bearer"
cfg.security_dangerous_bind_override = False
mock_get_config.return_value = cfg
passed, _warnings, fatal_errors = SecurityGate.verify_mandatory_controls()
self.assertFalse(passed)
self.assertTrue(any("S71 (hardened fail-closed)" in e for e in fatal_errors))
with self.assertRaises(RuntimeError):
enforce_startup_gate()
if __name__ == "__main__":
unittest.main()