Skip to content
Open

C #1

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Contributing to Secure OpenClaw Sandbox

## Setup

### 1. Fork i kloniranje

Forkaš originalni repo na GitHubu, zatim kloniraš **svoj fork**:

```bash
git clone https://github.com/<tvoj-username>/secure-openclaw-sandbox.git
cd secure-openclaw-sandbox
```

### 2. Dodaj upstream remote

```bash
git remote add upstream https://github.com/SmailG/secure-openclaw-sandbox.git

# Provjeri da imaš oba remota:
git remote -v
# origin https://github.com/<tvoj-username>/secure-openclaw-sandbox (fetch)
# origin https://github.com/<tvoj-username>/secure-openclaw-sandbox (push)
# upstream https://github.com/SmailG/secure-openclaw-sandbox (fetch)
# upstream https://github.com/SmailG/secure-openclaw-sandbox (push)
```

## Workflow za svaku izmjenu

### 3. Sync sa originalom prije rada

```bash
git fetch upstream
git checkout main
git merge upstream/main
git push origin main
```

### 4. Napravi novi branch

```bash
git checkout -b naziv-moje-izmjene
```

### 5. Napravi izmjene, commit i push

```bash
git add <fajlovi>
git commit -m "kratki opis izmjene"
git push origin naziv-moje-izmjene
```

### 6. Otvori Pull Request

Na GitHubu idi na tvoj fork → **Compare & pull request** → target je `SmailG/secure-openclaw-sandbox`.

## Napomene

- `origin` = tvoj fork (ovdje pushuješ)
- `upstream` = originalni repo (odavde povlačiš izmjene)
- Uvijek pravi **novi branch** za svaku izmjenu, nikad direktno na `main`
- PR mora proći `make test` provjeru prije mergea
5 changes: 5 additions & 0 deletions config/exec_policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ deny_patterns:
- "\\bdd\\s+if=.*\\s+of=/dev/"
- "\\bcurl\\b.*\\|\\s*(bash|sh)\\b"
- "\\bwget\\b.*\\|\\s*(bash|sh)\\b"
- "\\|\\s*(bash|sh)\\b"
- "\\$\\("
- "`"
- "\\$\\{?IFS\\}?"
- "\\bchmod\\s+777\\b"
- "\\bchown\\s+-R\\s+root\\b"
- "\\b(?:iptables|ufw)\\b"
- "/etc/(shadow|passwd|sudoers|gshadow)\\b"

allow_patterns:
- "^ls(\\s|$)"
Expand Down
76 changes: 70 additions & 6 deletions shield_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import unicodedata
import re
import shlex
import posixpath
import threading
from pathlib import Path
import ollama
Expand Down Expand Up @@ -62,9 +63,14 @@
r"\bdd\s+if=.*\s+of=/dev/",
r"\bcurl\b.*\|\s*(bash|sh)\b",
r"\bwget\b.*\|\s*(bash|sh)\b",
r"\|\s*(bash|sh)\b",
r"\$\(",
r"`",
r"\$\{?IFS\}?",
r"\bchmod\s+777\b",
r"\bchown\s+-R\s+root\b",
r"\b(?:iptables|ufw)\b",
r"/etc/(shadow|passwd|sudoers|gshadow)\b",
],
"allow_patterns": [
r"^ls(\s|$)",
Expand Down Expand Up @@ -92,10 +98,28 @@

# --- HELPER FUNCTIONS ---

_WHITESPACE_CONTROL_CHARS = {"\t", "\n", "\r", "\v", "\f"}


def normalize_input(text: str) -> str:
"""Collapses homoglyphs and removes hidden Unicode smuggling characters."""
"""Collapses homoglyphs and removes hidden Unicode smuggling characters.

Whitespace-acting control characters (tab, CR, LF, VT, FF) are mapped to a
regular space rather than deleted. Deleting them merges adjacent tokens
(e.g. "rm\\t-rf\\t/" -> "rm-rf/"), which silently defeats every
whitespace-anchored deny pattern (\\s+) downstream. Other control/format/
surrogate/private-use characters (category "C*"), such as zero-width
space or NUL, are still stripped entirely since they carry no legitimate
token-separating meaning.
"""
normalized = unicodedata.normalize("NFKC", text)
return "".join(ch for ch in normalized if unicodedata.category(ch)[0] != "C")
out = []
for ch in normalized:
if ch in _WHITESPACE_CONTROL_CHARS:
out.append(" ")
elif unicodedata.category(ch)[0] != "C":
out.append(ch)
return "".join(out)


def load_exec_policy() -> Dict[str, Any]:
Expand Down Expand Up @@ -135,7 +159,15 @@ def normalize_command(command: str, args: Optional[List[str]]) -> str:
cmd = normalize_input(command or "").strip()
safe_args = [normalize_input(a) for a in (args or []) if a is not None]
if safe_args:
return " ".join([cmd] + [shlex.quote(a) for a in safe_args]).strip()
# NOTE: args are joined as plain (normalized) text, NOT shell-quoted.
# This string is only ever used for policy-pattern matching and the
# LLM risk check below - it is never itself shell-executed - so
# shlex.quote() here provided no real safety benefit while actively
# breaking deny patterns anchored on trailing whitespace/end-of-string
# (e.g. a dangerous single-arg payload like "rm -rf /" would get
# wrapped as "'rm -rf /'", and the closing quote right after the "/"
# meant \brm\s+-rf\s+/(?:\s|$) no longer matched).
return " ".join([cmd] + safe_args).strip()
return cmd


Expand All @@ -147,6 +179,25 @@ def parse_primary_executable(command: str) -> str:
return Path(parts[0]).name if parts else ""


_SENSITIVE_FILES = {"/etc/shadow", "/etc/passwd", "/etc/sudoers", "/etc/gshadow"}


def contains_sensitive_path(command: str) -> bool:
"""Catches lexical path variants a literal-substring regex misses.

The deny_pattern for /etc/(shadow|passwd|...) only matches when that
exact text appears verbatim, so a harmless-looking detour like
"/etc/./shadow" (which resolves to the identical file on disk) slips
through untouched. This walks path-like tokens in the command and
lexically normalizes each one (no filesystem access) before comparing
against the sensitive-file list.
"""
for token in re.findall(r"/[^\s'\"]+", command):
if posixpath.normpath(token) in _SENSITIVE_FILES:
return True
return False


def evaluate_exec_policy(
normalized_command: str,
cwd: Optional[str],
Expand Down Expand Up @@ -177,6 +228,11 @@ def evaluate_exec_policy(
matches.append(f"deny_pattern:{idx}")
break

if contains_sensitive_path(normalized_command):
blocked = True
reasons.append("Command references a sensitive file path")
matches.append("sensitive_path")

if elevated:
elevated_rules = policy.get("elevated", {}) or {}
for idx, pat in enumerate(elevated_rules.get("deny_patterns", [])):
Expand All @@ -186,18 +242,26 @@ def evaluate_exec_policy(
matches.append(f"elevated_deny_pattern:{idx}")
break

cwd_val = (cwd or "").strip()
raw_cwd = (cwd or "").strip()
# Resolve lexically (no filesystem access, no symlink following needed) so
# that traversal segments can't be used to slip past a prefix check, e.g.
# cwd="/tmp/../etc" must be treated as "/etc", not as a "/tmp" path.
cwd_val = posixpath.normpath(raw_cwd) if raw_cwd else raw_cwd

def _prefix_match(path: str, prefix: str) -> bool:
return path == prefix or path.startswith(prefix.rstrip("/") + "/")

blocked_prefixes = policy.get("blocked_cwd_prefixes", []) or []
for prefix in blocked_prefixes:
if prefix and cwd_val.startswith(prefix):
if prefix and _prefix_match(cwd_val, prefix):
blocked = True
reasons.append("CWD is in blocked prefix")
matches.append(f"blocked_cwd:{prefix}")
break

allowed_prefixes = policy.get("allowed_cwd_prefixes", []) or []
if allowed_prefixes:
allowed = any(cwd_val.startswith(prefix) for prefix in allowed_prefixes if prefix)
allowed = any(_prefix_match(cwd_val, prefix) for prefix in allowed_prefixes if prefix)
if not allowed:
blocked = True
reasons.append("CWD is not in allowlisted prefixes")
Expand Down
Loading