Skip to content

Commit 56ccecf

Browse files
committed
feat: implement platform-specific enforcement feedback and toast notifications
1 parent eb1421f commit 56ccecf

File tree

6 files changed

+233
-27
lines changed

6 files changed

+233
-27
lines changed

deploy/dashboard/app.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,50 @@ async def update_user_rules(request: Request):
186186
if "custom_rules" not in body and "custom_rules" in existing:
187187
body["custom_rules"] = existing["custom_rules"]
188188
save_user_rules(body)
189-
return JSONResponse({"status": "ok", "path": str(USER_RULES_PATH)})
189+
_trigger_enforcement()
190+
return JSONResponse({
191+
"status": "ok",
192+
"path": str(USER_RULES_PATH),
193+
"message": _get_enforcement_message()
194+
})
190195
except Exception as exc:
191196
return JSONResponse({"error": str(exc)}, status_code=400)
192197

193198

199+
def _trigger_enforcement():
200+
"""Trigger background daemon/policy hot reloads when user rules change."""
201+
import platform
202+
import subprocess
203+
import threading
204+
205+
def _run():
206+
if platform.system() == "Darwin":
207+
# Re-generate the Seatbelt profile and notify user
208+
apply_script = os.path.join(os.path.dirname(__file__), "..", "apply_macos_policy.py")
209+
if os.path.exists(apply_script):
210+
try:
211+
subprocess.run(["python3", apply_script], check=True, capture_output=True)
212+
logger.info("Triggered macOS policy applicator")
213+
except subprocess.CalledProcessError as e:
214+
logger.error("Failed to apply macOS policy: %s", e.stderr.decode() if e.stderr else str(e))
215+
else:
216+
# On Linux, monitor.py automatically reloads on mtime changes.
217+
# Touching the user_rules file ensures the trigger fires immediately if needed.
218+
if USER_RULES_PATH.exists():
219+
os.utime(USER_RULES_PATH, None)
220+
logger.info("Triggered Linux monitor reload")
221+
222+
threading.Thread(target=_run, daemon=True).start()
223+
224+
225+
def _get_enforcement_message():
226+
"""Return a human-readable message about enforcement latency/actions."""
227+
import platform
228+
if platform.system() == "Darwin":
229+
return "Changes applied. Please restart OpenClaw to enforce new kernel-level rules."
230+
return "Changes applied and effective immediately."
231+
232+
194233
@app.get("/api/custom-rules")
195234
async def list_custom_rules():
196235
"""Return all user-defined custom blocking rules."""
@@ -211,7 +250,13 @@ async def create_custom_rule(request: Request):
211250
rule, err = add_custom_rule(rule_type, value, platform)
212251
if err:
213252
return JSONResponse({"error": err}, status_code=400)
214-
return JSONResponse({"status": "ok", "rule": rule})
253+
254+
_trigger_enforcement()
255+
return JSONResponse({
256+
"status": "ok",
257+
"rule": rule,
258+
"message": _get_enforcement_message()
259+
})
215260
except Exception as exc:
216261
return JSONResponse({"error": str(exc)}, status_code=400)
217262

@@ -228,7 +273,13 @@ async def modify_custom_rule(rule_id: str, request: Request):
228273
)
229274
if err:
230275
return JSONResponse({"error": err}, status_code=400)
231-
return JSONResponse({"status": "ok", "rule": rule})
276+
277+
_trigger_enforcement()
278+
return JSONResponse({
279+
"status": "ok",
280+
"rule": rule,
281+
"message": _get_enforcement_message()
282+
})
232283
except Exception as exc:
233284
return JSONResponse({"error": str(exc)}, status_code=400)
234285

@@ -239,7 +290,13 @@ async def remove_custom_rule(rule_id: str):
239290
ok, err = delete_custom_rule(rule_id)
240291
if not ok:
241292
return JSONResponse({"error": err}, status_code=404)
242-
return JSONResponse({"status": "ok", "deleted": rule_id})
293+
294+
_trigger_enforcement()
295+
return JSONResponse({
296+
"status": "ok",
297+
"deleted": rule_id,
298+
"message": _get_enforcement_message()
299+
})
243300

244301

245302
@app.get("/api/status")

deploy/dashboard/templates/index.html

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -983,10 +983,69 @@
983983
font-weight: 600;
984984
color: var(--text-primary);
985985
}
986+
987+
/* Toast */
988+
.toast-container {
989+
position: fixed;
990+
top: 24px;
991+
right: 24px;
992+
display: flex;
993+
flex-direction: column;
994+
gap: 12px;
995+
z-index: 1000;
996+
pointer-events: none;
997+
}
998+
999+
.toast {
1000+
background: var(--bg-card);
1001+
border: 1px solid var(--border);
1002+
border-left: 4px solid var(--accent);
1003+
padding: 14px 20px;
1004+
border-radius: var(--radius-sm);
1005+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
1006+
font-size: 13px;
1007+
color: var(--text-primary);
1008+
min-width: 280px;
1009+
max-width: 400px;
1010+
transform: translateX(120%);
1011+
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
1012+
pointer-events: auto;
1013+
display: flex;
1014+
align-items: center;
1015+
justify-content: space-between;
1016+
gap: 12px;
1017+
}
1018+
1019+
.toast.visible {
1020+
transform: translateX(0);
1021+
}
1022+
1023+
.toast.success {
1024+
border-left-color: var(--success);
1025+
}
1026+
1027+
.toast.warning {
1028+
border-left-color: var(--warning);
1029+
}
1030+
1031+
.toast.danger {
1032+
border-left-color: var(--danger);
1033+
}
1034+
1035+
.toast-close {
1036+
background: none;
1037+
border: none;
1038+
color: var(--text-muted);
1039+
cursor: pointer;
1040+
font-size: 16px;
1041+
padding: 0;
1042+
display: flex;
1043+
}
9861044
</style>
9871045
</head>
9881046

9891047
<body>
1048+
<div class="toast-container" id="toastContainer"></div>
9901049
<div class="app-layout">
9911050
<!-- Sidebar -->
9921051
<aside class="sidebar">
@@ -1326,6 +1385,25 @@ <h2 id="modalTitle">Add Custom Rule</h2>
13261385
}
13271386
function shouldShowRule(r) { const p = getEffectivePlatform(); if (!p) return true; return r.platform === 'general' || r.platform === p; }
13281387

1388+
// ── Toast ──
1389+
function showToast(message, type = 'info') {
1390+
const container = document.getElementById('toastContainer');
1391+
const toast = document.createElement('div');
1392+
toast.className = `toast ${type}`;
1393+
toast.innerHTML = `
1394+
<span>${esc(message)}</span>
1395+
<button class="toast-close" onclick="this.parentElement.classList.remove('visible'); setTimeout(() => this.parentElement.remove(), 300)">✕</button>
1396+
`;
1397+
container.appendChild(toast);
1398+
// Force reflow
1399+
toast.offsetHeight;
1400+
toast.classList.add('visible');
1401+
setTimeout(() => {
1402+
toast.classList.remove('visible');
1403+
setTimeout(() => toast.remove(), 300);
1404+
}, 5000);
1405+
}
1406+
13291407
// ── API ──
13301408
async function fetchJSON(u) { return (await fetch(u)).json(); }
13311409

@@ -1498,9 +1576,11 @@ <h2 id="modalTitle">Add Custom Rule</h2>
14981576
async function saveExemptions() {
14991577
if (!pendingExemptions) return;
15001578
try {
1501-
await fetch('/api/user-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ exempted_rule_ids: Array.from(pendingExemptions) }) });
1579+
const res = await fetch('/api/user-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ exempted_rule_ids: Array.from(pendingExemptions) }) });
1580+
const data = await res.json();
15021581
exemptedIds = new Set(pendingExemptions); pendingExemptions = null;
15031582
document.getElementById('saveBanner').classList.remove('visible'); renderRules();
1583+
if (data.message) showToast(data.message, 'success');
15041584
} catch (e) { alert('Failed to save: ' + e.message); }
15051585
}
15061586
function cancelExemptions() { pendingExemptions = null; document.getElementById('saveBanner').classList.remove('visible'); renderRules(); }
@@ -1582,6 +1662,7 @@ <h2 id="modalTitle">Add Custom Rule</h2>
15821662
if (data.error) { errEl.textContent = data.error; errEl.classList.add('visible'); return; }
15831663
closeModal();
15841664
await loadCustomRules();
1665+
if (data.message) showToast(data.message, 'success');
15851666
} catch (e) { errEl.textContent = 'Request failed: ' + e.message; errEl.classList.add('visible'); }
15861667
}
15871668

@@ -1592,6 +1673,7 @@ <h2 id="modalTitle">Add Custom Rule</h2>
15921673
const data = await res.json();
15931674
if (data.error) { alert(data.error); return; }
15941675
await loadCustomRules();
1676+
if (data.message) showToast(data.message, 'success');
15951677
} catch (e) { alert('Failed to delete: ' + e.message); }
15961678
}
15971679

deploy/install.sh

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,16 @@ install_dashboard_deps() {
6262
# 2. --ignore-installed (skip uninstall of apt-managed packages)
6363
# 3. --force-reinstall as last resort
6464
if $PIP install --quiet --break-system-packages --ignore-installed \
65-
fastapi uvicorn typing-extensions pydantic 2>/dev/null; then
65+
fastapi uvicorn typing-extensions pydantic pyyaml 2>/dev/null; then
6666
log "Dashboard dependencies installed successfully"
6767
elif $PIP install --quiet --break-system-packages --force-reinstall \
68-
fastapi uvicorn typing-extensions pydantic 2>/dev/null; then
68+
fastapi uvicorn typing-extensions pydantic pyyaml 2>/dev/null; then
6969
log "Dashboard dependencies installed (force-reinstall)"
70-
elif $PIP install --quiet fastapi uvicorn typing-extensions pydantic 2>/dev/null; then
70+
elif $PIP install --quiet fastapi uvicorn typing-extensions pydantic pyyaml 2>/dev/null; then
7171
log "Dashboard dependencies installed"
7272
else
73-
log "WARNING: Could not install fastapi/uvicorn via pip."
74-
log " Install manually: pip3 install fastapi uvicorn typing-extensions pydantic"
73+
log "WARNING: Could not install dependencies via pip."
74+
log " Install manually: pip3 install fastapi uvicorn typing-extensions pydantic pyyaml"
7575
fi
7676
}
7777

@@ -197,6 +197,8 @@ install_macos() {
197197

198198
mkdir -p "$CLAWEDR_DIR/shared"
199199
mkdir -p "$CLAWEDR_DIR/dashboard/templates"
200+
mkdir -p "/etc/clawedr"
201+
chmod 777 "/etc/clawedr" || true
200202
cp "$tmpdir/clawedr.sb" "$CLAWEDR_DIR/"
201203
cp "$tmpdir/log_tailer.py" "$CLAWEDR_DIR/"
202204
cp "$tmpdir/apply_macos_policy.py" "$CLAWEDR_DIR/"
@@ -253,6 +255,8 @@ install_linux() {
253255

254256
mkdir -p "$CLAWEDR_DIR/shared"
255257
mkdir -p "$CLAWEDR_DIR/dashboard/templates"
258+
mkdir -p "/etc/clawedr"
259+
chmod 777 "/etc/clawedr" || true
256260
cp "$tmpdir/compiled_policy.json" "$CLAWEDR_DIR/"
257261
cp "$tmpdir/bpf_hooks.c" "$CLAWEDR_DIR/"
258262
cp "$tmpdir/monitor.py" "$CLAWEDR_DIR/"

deploy/linux/monitor.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343

4444
# Add parent directory to path so we can import shared modules
4545
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
46-
from shared.user_rules import get_exempted_rule_ids, get_custom_rules
46+
from shared.user_rules import get_exempted_rule_ids, get_custom_rules, USER_RULES_PATH
4747
from shared.alert_dispatcher import dispatch_alert_async
4848

4949
logger = logging.getLogger("clawedr.monitor")
@@ -640,17 +640,22 @@ def _stop(signum, frame):
640640
while running:
641641
now = time.monotonic()
642642
if now >= next_policy_check:
643-
try:
644-
mtime = os.path.getmtime(path)
643+
mtimes = []
644+
for check_path in (path, USER_RULES_PATH):
645+
try:
646+
mtimes.append(os.path.getmtime(check_path))
647+
except FileNotFoundError:
648+
pass
649+
650+
if mtimes:
651+
mtime = max(mtimes)
645652
if mtime != last_mtime:
646-
logger.info("Policy file changed (mtime %.0f → %.0f), reloading", last_mtime, mtime)
653+
logger.info("Policy or user rules changed (mtime %.0f → %.0f), reloading", last_mtime, mtime)
647654
policy = load_policy(path)
648655
apply_policy(policy)
649656
last_mtime = mtime
650-
except FileNotFoundError:
651-
logger.warning("Policy file not found at %s — waiting", path)
652-
except json.JSONDecodeError as exc:
653-
logger.error("Corrupt policy JSON: %s", exc)
657+
658+
# (Exception handling merged appropriately above)
654659
next_policy_check = now + interval
655660

656661
if _bpf_instance is not None:

deploy/macos/log_tailer.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
# Add parent directory to path so we can import shared modules
2525
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
2626
from shared.alert_dispatcher import dispatch_alert_async
27+
from shared.user_rules import get_custom_rules, USER_RULES_PATH
2728

2829
logger = logging.getLogger("clawedr.log_tailer")
2930

@@ -47,14 +48,36 @@ def _load_policy_rule_index() -> dict:
4748
"""Load compiled_policy.json to allow cross-referencing sandbox violations."""
4849
try:
4950
with open(POLICY_PATH) as f:
50-
return json.load(f)
51+
policy = json.load(f)
52+
53+
# Merge in custom user rules to the runtime mapping payload
54+
custom_rules = get_custom_rules()
55+
for rule in custom_rules:
56+
rid = rule.get("id", "")
57+
rtype = rule.get("type", "")
58+
val = rule.get("value", "")
59+
plat = rule.get("platform", "both")
60+
if plat not in ("both", "macos") or not rid:
61+
continue
62+
63+
if rtype == "executable":
64+
policy.setdefault("blocked_executables", {})[rid] = val
65+
elif rtype == "domain":
66+
policy.setdefault("blocked_domains", {})[rid] = val
67+
elif rtype == "path":
68+
policy.setdefault("blocked_paths", {}).setdefault("macos", {})[rid] = val
69+
elif rtype == "argument":
70+
policy.setdefault("deny_rules", {}).setdefault("macos", {})[rid] = val
71+
72+
return policy
5173
except (FileNotFoundError, json.JSONDecodeError) as exc:
5274
logger.warning("Could not load policy for rule index: %s", exc)
5375
return {}
5476

5577

5678
def tail_sandbox_log():
5779
"""Poll macOS sandbox violation events from the Unified Log."""
80+
last_mtime = 0.0
5881
rule_index = _load_policy_rule_index()
5982
seen_events = set()
6083
logger.info("Starting log show polling for sandbox reporting...")
@@ -64,6 +87,12 @@ def tail_sandbox_log():
6487

6588
while True:
6689
try:
90+
# Refresh rule index if policy or user rules changed
91+
changed, last_mtime = check_for_policy_update(last_mtime)
92+
if changed:
93+
logger.info("Policy or user rules updated, reloading rule index...")
94+
rule_index = _load_policy_rule_index()
95+
6796
# Check the last 15 seconds to overlap and avoid missing events
6897
start_time_str = (datetime.now() - timedelta(seconds=15)).strftime("%Y-%m-%d %H:%M:%S")
6998
cmd = [
@@ -135,12 +164,24 @@ def tail_sandbox_log():
135164

136165

137166
def check_for_policy_update(last_mtime: float) -> tuple[bool, float]:
138-
"""Check if the policy file has been updated since last_mtime."""
139-
try:
140-
mtime = os.path.getmtime(POLICY_PATH)
141-
return (mtime != last_mtime, mtime)
142-
except FileNotFoundError:
167+
"""Check if the system policy or user rules have been updated since last_mtime."""
168+
mtimes = []
169+
170+
for path in (POLICY_PATH, USER_RULES_PATH):
171+
try:
172+
mtimes.append(os.path.getmtime(path))
173+
except FileNotFoundError:
174+
pass
175+
176+
if not mtimes:
143177
return (False, last_mtime)
178+
179+
current_max_mtime = max(mtimes)
180+
181+
if current_max_mtime != last_mtime:
182+
return (True, current_max_mtime)
183+
184+
return (False, last_mtime)
144185

145186

146187
def notify_user(message: str) -> None:

0 commit comments

Comments
 (0)