Skip to content

Commit d436ce2

Browse files
committed
Add off-alert-enforce toggle for all rules, collapse heuristics by default
- Extend tri-toggle (Off/Alert/Enforce) to security and custom rules, not just heuristics - BPF maps now store mode: 1=alert (log only), 2=enforce (block) - Add rule_mode_overrides in user_rules.yaml for security+custom rules - Dashboard: tri-toggle for security rules and custom rules - Heuristics categories collapsed by default in Policy Rules tab - Toggle changes apply instantly (SIGHUP to monitor, policy reload) - macOS: only enforce rules added to Seatbelt profile Made-with: Cursor
1 parent 31de748 commit d436ce2

File tree

6 files changed

+243
-90
lines changed

6 files changed

+243
-90
lines changed

deploy/dashboard/app.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
get_custom_rules, get_custom_rule_metadata, CUSTOM_RULE_TYPES,
3838
load_settings, save_settings,
3939
get_heuristic_overrides, save_heuristic_overrides, set_group_heuristic_mode,
40+
get_rule_mode_overrides, save_rule_mode_overrides,
4041
VALID_HEURISTIC_MODES,
4142
)
4243
from shared.rule_updater import check_for_updates, download_and_apply
@@ -64,7 +65,7 @@
6465
_BLOCK_LINE_RE = re.compile(
6566
r"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}[,.]?\d*)\s+"
6667
r"WARNING\s+\[[\w.]+\]\s+"
67-
r"(?:BLOCKED|WARNING)\s+\[([A-Z0-9_-]+)\]\s+(.*)"
68+
r"(BLOCKED|ALERT|WARNING)\s+\[([A-Z0-9_-]+)\]\s+(.*)"
6869
)
6970

7071

@@ -117,10 +118,12 @@ def _parse_log_lines(max_lines: int = 1000) -> list[dict]:
117118
for line in lines[-max_lines:]:
118119
m = _BLOCK_LINE_RE.search(line)
119120
if m:
121+
kind = m.group(2).upper() # BLOCKED, ALERT, WARNING
120122
alerts.append({
121123
"timestamp": m.group(1),
122-
"rule_id": m.group(2),
123-
"details": m.group(3).strip(),
124+
"rule_id": m.group(3),
125+
"details": m.group(4).strip(),
126+
"blocked": kind != "ALERT",
124127
})
125128
except (PermissionError, OSError):
126129
continue
@@ -237,12 +240,23 @@ async def update_user_rules(request: Request):
237240
"""Update the user's rule exemptions (preserves custom_rules and heuristic_overrides)."""
238241
try:
239242
body = await request.json()
240-
# Preserve existing custom_rules and heuristic_overrides when only exemptions are being saved
243+
# Preserve existing when not in body
241244
existing = load_user_rules()
242245
if "custom_rules" not in body and "custom_rules" in existing:
243246
body["custom_rules"] = existing["custom_rules"]
244247
if "heuristic_overrides" not in body and "heuristic_overrides" in existing:
245248
body["heuristic_overrides"] = existing["heuristic_overrides"]
249+
if "rule_mode_overrides" not in body and "rule_mode_overrides" in existing:
250+
body["rule_mode_overrides"] = existing["rule_mode_overrides"]
251+
# Sync exempted_rule_ids from rule_mode_overrides (disabled = exempted)
252+
if "rule_mode_overrides" in body and isinstance(body["rule_mode_overrides"], dict):
253+
exempted = set(existing.get("exempted_rule_ids", []))
254+
for rid, m in body["rule_mode_overrides"].items():
255+
if m == "disabled":
256+
exempted.add(rid)
257+
else:
258+
exempted.discard(rid)
259+
body["exempted_rule_ids"] = list(exempted)
246260
save_user_rules(body)
247261
_trigger_enforcement()
248262
return JSONResponse({

deploy/dashboard/templates/index.html

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,7 @@ <h2 id="modalTitle">Add Custom Rule</h2>
19011901
}
19021902

19031903
function isMonitoredAlert(a) {
1904+
if (a.blocked === false) return true;
19041905
const d = (a.details || '').toLowerCase();
19051906
return d.includes('monitored') || d.includes('observed') || d.includes('action=allow');
19061907
}
@@ -1953,13 +1954,16 @@ <h2 id="modalTitle">Add Custom Rule</h2>
19531954
const d = await fetchJSON('/api/user-rules');
19541955
exemptedIds = new Set(d.rules.exempted_rule_ids || []);
19551956
heuristicOverrides = d.rules.heuristic_overrides || {};
1957+
ruleModeOverrides = d.rules.rule_mode_overrides || {};
19561958
pendingExemptions = null;
19571959
pendingHeuristics = null;
1960+
pendingRuleModes = null;
19581961
renderRules();
19591962
}
19601963
catch {
19611964
exemptedIds = new Set();
19621965
heuristicOverrides = {};
1966+
ruleModeOverrides = {};
19631967
}
19641968
}
19651969

@@ -2016,7 +2020,8 @@ <h2 id="modalTitle">Add Custom Rule</h2>
20162020
const platLabel = plat === 'both' ? '' : '(' + plat + ')';
20172021
const sev = r.severity ? `<span class="rule-tag severity-${r.severity.toLowerCase()}">${esc(r.severity)}</span>` : '';
20182022
const desc = r.description ? `<div style="font-size:12px;color:var(--text-muted);margin-top:4px;width:100%;">${esc(r.description)}</div>` : '';
2019-
html += `<div class="rule-item">
2023+
const mode = (pendingRuleModes ?? ruleModeOverrides)[r.id] || (exemptedIds.has(r.id) ? 'disabled' : 'enforce');
2024+
html += `<div class="rule-item" style="${mode === 'disabled' ? 'opacity:0.5;' : ''}">
20202025
<div class="rule-info" style="flex-wrap:wrap;">
20212026
<span class="rule-tag custom">${r.id}</span>
20222027
<span style="font-size:16px;flex-shrink:0;">${typeIcons[r.type] || '📌'}</span>
@@ -2025,7 +2030,8 @@ <h2 id="modalTitle">Add Custom Rule</h2>
20252030
<span style="font-size:11px;color:var(--text-muted);flex-shrink:0;">${r.type} ${platLabel}</span>
20262031
${desc}
20272032
</div>
2028-
<div class="rule-actions">
2033+
<div class="rule-actions" style="display:flex; align-items:center; gap:8px;">
2034+
<div class="tri-toggle mode-${mode}"><div class="tri-slider"></div><div class="tri-btn" data-val="disabled" onclick="toggleRuleMode('${r.id}', 'disabled')">🚫 Off</div><div class="tri-btn" data-val="alert" onclick="toggleRuleMode('${r.id}', 'alert')">👀 Alert</div><div class="tri-btn" data-val="enforce" onclick="toggleRuleMode('${r.id}', 'enforce')">🛡️ Enforce</div></div>
20292035
<button class="btn btn-ghost btn-sm" onclick="editCustomRule('${r.id}')">Edit</button>
20302036
<button class="btn btn-danger btn-sm" onclick="deleteCustomRule('${r.id}')">Delete</button>
20312037
</div>
@@ -2036,6 +2042,8 @@ <h2 id="modalTitle">Add Custom Rule</h2>
20362042

20372043
// ── Render System Rules ──
20382044
let activeRuleCategory = 'security';
2045+
let ruleModeOverrides = {};
2046+
let pendingRuleModes = null;
20392047
function setRuleCategory(cat) {
20402048
activeRuleCategory = cat;
20412049
document.getElementById('btnCatSecurity').style.background = cat === 'security' ? 'var(--accent)' : 'transparent';
@@ -2045,18 +2053,29 @@ <h2 id="modalTitle">Add Custom Rule</h2>
20452053
renderRules();
20462054
}
20472055

2056+
function getSecurityRuleMode(r) {
2057+
const cur = pendingRuleModes ?? ruleModeOverrides;
2058+
if (cur[r.id]) return cur[r.id];
2059+
return exemptedIds.has(r.id) ? 'disabled' : 'enforce';
2060+
}
2061+
20482062
function renderRules() {
20492063
const c = document.getElementById('rulesContainer');
2050-
const curExempts = pendingExemptions || exemptedIds;
2051-
const curHeuristics = pendingHeuristics || heuristicOverrides;
2064+
const curExempts = pendingExemptions ?? exemptedIds;
2065+
const curHeuristics = pendingHeuristics ?? heuristicOverrides;
2066+
const curRuleModes = pendingRuleModes ?? ruleModeOverrides;
20522067
const q = (document.getElementById('ruleSearch')?.value || '').toLowerCase();
20532068

20542069
const visible = allRuleItems.filter(r => {
20552070
if (r.type !== activeRuleCategory) return false;
20562071
return shouldShowRule(r);
20572072
});
20582073

2059-
document.getElementById('exemptionCount').textContent = `${curExempts.size} exempted`;
2074+
const exemptCount = [...visible].filter(r => {
2075+
const m = activeRuleCategory === 'security' ? getSecurityRuleMode(r) : (curHeuristics[r.id] || r.defaultAction || 'alert');
2076+
return m === 'disabled';
2077+
}).length;
2078+
document.getElementById('exemptionCount').textContent = `${exemptCount} off`;
20602079

20612080
if (!visible.length) {
20622081
c.innerHTML = '<div class="empty-state"><p>No rules match your search for this category.</p></div>';
@@ -2073,18 +2092,17 @@ <h2 id="modalTitle">Add Custom Rule</h2>
20732092
let html = '';
20742093
for (const [cat, items] of Object.entries(groups)) {
20752094
if (activeRuleCategory === 'security') {
2076-
const ec = items.filter(r => curExempts.has(r.id)).length;
2077-
html += `<div class="rule-group"><div class="rule-group-title">${ec ? `${cat} (${items.length}) · ${ec} exempted` : `${cat} (${items.length})`}</div>`;
2095+
html += `<div class="rule-group"><div class="rule-group-title">${cat} (${items.length})</div>`;
20782096
for (const r of items) {
2079-
const ex = curExempts.has(r.id);
2097+
const mode = getSecurityRuleMode(r);
20802098
const typeBadge = ruleTypeBadge(r.id);
2081-
html += `<div class="rule-item" style="${ex ? 'opacity:0.5;' : ''}"><div class="rule-info">${typeBadge}<span class="rule-tag ${ex ? 'exempted' : ''}">${r.id}</span><span class="rule-value" title="${esc(r.val)}">${esc(r.val)}</span></div><label class="toggle"><input type="checkbox" ${ex ? '' : 'checked'} onchange="toggleExemption('${r.id}',this.checked)"><span class="toggle-slider"></span></label></div>`;
2099+
html += `<div class="rule-item" style="${mode === 'disabled' ? 'opacity:0.5;' : ''}"><div class="rule-info">${typeBadge}<span class="rule-tag ${mode === 'disabled' ? 'exempted' : ''}">${r.id}</span><span class="rule-value" title="${esc(r.val)}">${esc(r.val)}</span></div><div class="rule-actions" style="min-width:100px;"><div class="tri-toggle mode-${mode}"><div class="tri-slider"></div><div class="tri-btn" data-val="disabled" onclick="toggleRuleMode('${r.id}', 'disabled')">🚫 Off</div><div class="tri-btn" data-val="alert" onclick="toggleRuleMode('${r.id}', 'alert')">👀 Alert</div><div class="tri-btn" data-val="enforce" onclick="toggleRuleMode('${r.id}', 'enforce')">🛡️ Enforce</div></div></div></div>`;
20822100
}
20832101
html += '</div>';
20842102
} else {
20852103
// Heuristics
20862104
const rids = items.map(r => r.id).join(',');
2087-
html += `<details class="rule-group-details" open style="padding-bottom:12px; margin-bottom: 8px;">
2105+
html += `<details class="rule-group-details" style="padding-bottom:12px; margin-bottom: 8px;">
20882106
<summary class="rule-group-title" style="display:flex; justify-content:space-between; align-items:center;">
20892107
<span><span class="group-arrow">▶</span>${cat} (${items.length})</span>
20902108
<div style="display:flex; gap:8px; align-items:center; font-size:12px; font-weight:normal;">
@@ -2141,6 +2159,20 @@ <h2 id="modalTitle">Add Custom Rule</h2>
21412159
renderRules();
21422160
}
21432161

2162+
function toggleRuleMode(id, mode) {
2163+
if (!pendingRuleModes) pendingRuleModes = { ...ruleModeOverrides };
2164+
pendingRuleModes[id] = mode;
2165+
if (mode === 'disabled') {
2166+
if (!pendingExemptions) pendingExemptions = new Set(exemptedIds);
2167+
pendingExemptions.add(id);
2168+
} else {
2169+
if (!pendingExemptions) pendingExemptions = new Set(exemptedIds);
2170+
pendingExemptions.delete(id);
2171+
}
2172+
document.getElementById('saveBanner').classList.add('visible');
2173+
renderRules();
2174+
}
2175+
21442176
function toggleHeuristic(id, mode) {
21452177
if (!pendingHeuristics) pendingHeuristics = { ...heuristicOverrides };
21462178
pendingHeuristics[id] = mode;
@@ -2161,16 +2193,25 @@ <h2 id="modalTitle">Add Custom Rule</h2>
21612193

21622194
async function saveExemptions() {
21632195
try {
2196+
const body = {};
21642197
if (pendingExemptions) {
2165-
await fetch('/api/user-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ exempted_rule_ids: Array.from(pendingExemptions) }) });
2198+
body.exempted_rule_ids = Array.from(pendingExemptions);
21662199
exemptedIds = new Set(pendingExemptions);
21672200
pendingExemptions = null;
21682201
}
2202+
if (pendingRuleModes) {
2203+
body.rule_mode_overrides = { ...ruleModeOverrides, ...pendingRuleModes };
2204+
ruleModeOverrides = body.rule_mode_overrides;
2205+
pendingRuleModes = null;
2206+
}
21692207
if (pendingHeuristics) {
2170-
await fetch('/api/heuristic-overrides', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ overrides: pendingHeuristics }) });
2171-
heuristicOverrides = { ...pendingHeuristics };
2208+
body.heuristic_overrides = { ...heuristicOverrides, ...pendingHeuristics };
2209+
heuristicOverrides = body.heuristic_overrides;
21722210
pendingHeuristics = null;
21732211
}
2212+
if (Object.keys(body).length) {
2213+
await fetch('/api/user-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
2214+
}
21742215
document.getElementById('saveBanner').classList.remove('visible');
21752216
renderRules();
21762217
showToast("Changes applied.", 'success');
@@ -2180,6 +2221,7 @@ <h2 id="modalTitle">Add Custom Rule</h2>
21802221
function cancelExemptions() {
21812222
pendingExemptions = null;
21822223
pendingHeuristics = null;
2224+
pendingRuleModes = null;
21832225
document.getElementById('saveBanner').classList.remove('visible');
21842226
renderRules();
21852227
}
@@ -2190,10 +2232,7 @@ <h2 id="modalTitle">Add Custom Rule</h2>
21902232
toggleHeuristic(id, 'disabled');
21912233
} else {
21922234
setRuleCategory('security');
2193-
if (!pendingExemptions) pendingExemptions = new Set(exemptedIds);
2194-
pendingExemptions.add(id);
2195-
document.getElementById('saveBanner').classList.add('visible');
2196-
renderRules();
2235+
toggleRuleMode(id, 'disabled');
21972236
}
21982237
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
21992238
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));

deploy/linux/bpf_hooks.c

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ struct event_t {
4141
char filename[MAX_FILENAME_LEN];
4242
u8 action; // 0 = observed (enter), 1 = blocked (SIGKILL), 2 = post-exec
4343
// (exit, for deny_rules), 3 = connect_attempt (userspace domain
44-
// check), 4 = heuristic_alert, 5 = heuristic_block
44+
// check), 4 = heuristic_alert, 5 = heuristic_block,
45+
// 6 = security_alert (match but no kill)
4546
u32 blocked_ip; // for connect events: IP in host byte order
4647
u16 heu_slot; // for heuristic events: slot index for rule_id lookup
4748
};
@@ -238,12 +239,13 @@ TRACEPOINT_PROBE(syscalls, sys_enter_execve) {
238239
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
239240
bpf_probe_read_user_str(&evt.filename, sizeof(evt.filename), fname);
240241

241-
/* Layer 1: blocked executable */
242-
u8 *is_blocked = blocked_hashes.lookup(&h);
243-
if (is_blocked) {
244-
evt.action = 1;
242+
/* Layer 1: blocked executable — value 1=alert, 2=enforce */
243+
u8 *mode = blocked_hashes.lookup(&h);
244+
if (mode) {
245+
evt.action = (*mode == 2) ? 1 : 6;
245246
events.perf_submit(args, &evt, sizeof(evt));
246-
bpf_send_signal(SIGKILL);
247+
if (*mode == 2)
248+
bpf_send_signal(SIGKILL);
247249
return 0;
248250
}
249251

@@ -373,22 +375,22 @@ TRACEPOINT_PROBE(syscalls, sys_enter_openat) {
373375
const char *fname = args->filename;
374376
u64 h = simple_hash(fname, MAX_FILENAME_LEN);
375377

376-
u8 *is_blocked = blocked_path_hashes.lookup(&h);
377-
if (is_blocked) {
378+
u8 *mode = blocked_path_hashes.lookup(&h);
379+
if (mode) {
378380
struct event_t evt = {};
379381
evt.pid = pid;
380382
evt.ns_pid = get_ns_pid();
381383
evt.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
382384
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
383385
bpf_probe_read_user_str(&evt.filename, sizeof(evt.filename), fname);
384-
evt.action = 1;
386+
evt.action = (*mode == 2) ? 1 : 6;
385387
events.perf_submit(args, &evt, sizeof(evt));
386388
#ifdef CLAWEDR_USE_LSM
387-
/* LSM file_open will block with -EPERM; don't kill (keeps OpenClaw intact)
388-
*/
389+
if (*mode == 2)
390+
/* LSM file_open will block with -EPERM; don't kill (keeps OpenClaw intact) */
391+
;
389392
#else
390-
/* Only SIGKILL subprocesses; protect OpenClaw gateway from being killed */
391-
if (!protected_pids.lookup(&pid))
393+
if (*mode == 2 && !protected_pids.lookup(&pid))
392394
bpf_send_signal(SIGKILL);
393395
#endif
394396
}
@@ -408,36 +410,31 @@ TRACEPOINT_PROBE(syscalls, sys_enter_statx) {
408410
const char *fname = (const char *)args->filename;
409411
u64 h = simple_hash(fname, MAX_FILENAME_LEN);
410412

411-
u8 *is_blocked = blocked_path_hashes.lookup(&h);
412-
if (is_blocked) {
413+
u8 *mode = blocked_path_hashes.lookup(&h);
414+
if (mode) {
413415
struct event_t evt = {};
414416
evt.pid = pid;
415417
evt.ns_pid = get_ns_pid();
416418
evt.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
417419
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
418420
bpf_probe_read_user_str(&evt.filename, sizeof(evt.filename), fname);
419-
evt.action = 1;
421+
evt.action = (*mode == 2) ? 1 : 6;
420422
events.perf_submit(args, &evt, sizeof(evt));
421-
/* statx has no LSM equivalent; SIGKILL subprocesses but protect OpenClaw
422-
* gateway */
423-
if (!protected_pids.lookup(&pid))
423+
if (*mode == 2 && !protected_pids.lookup(&pid))
424424
bpf_send_signal(SIGKILL);
425425
}
426426

427427
return 0;
428428
}
429429

430-
/* Helper: check blocked IP and optionally kill (used by sendto tracepoint) */
431-
static __always_inline int _check_blocked_ip(void *ctx, u32 pid, u32 ip,
432-
int do_kill) {
433-
u8 *is_blocked = blocked_ips.lookup(&ip);
434-
if (!is_blocked)
430+
/* Helper: check blocked IP — value 1=alert, 2=enforce */
431+
static __always_inline int _check_blocked_ip(void *ctx, u32 pid, u32 ip) {
432+
u8 *mode = blocked_ips.lookup(&ip);
433+
if (!mode)
435434
return 0;
436435

437436
char comm[TASK_COMM_LEN];
438437
bpf_get_current_comm(&comm, sizeof(comm));
439-
/* Exempt sudo/systemctl: may connect to 8.8.8.8 for DNS; child processes
440-
* (e.g. curl) still blocked */
441438
if (comm[0] == 's' && comm[1] == 'u' && comm[2] == 'd' && comm[3] == 'o' &&
442439
(comm[4] == '\0' || comm[4] == ' '))
443440
return 0;
@@ -454,9 +451,9 @@ static __always_inline int _check_blocked_ip(void *ctx, u32 pid, u32 ip,
454451
evt.blocked_ip = ip;
455452
const char msg[] = "NETWORK_CONNECT";
456453
__builtin_memcpy((void *)evt.filename, msg, sizeof(msg));
457-
evt.action = 1;
454+
evt.action = (*mode == 2) ? 1 : 6;
458455
events.perf_submit(ctx, &evt, sizeof(evt));
459-
if (do_kill)
456+
if (*mode == 2)
460457
bpf_send_signal(SIGKILL);
461458
return 1;
462459
}
@@ -486,8 +483,8 @@ LSM_PROBE(socket_connect, struct socket *sock, struct sockaddr *address,
486483
u32 ip = 0;
487484
bpf_probe_read_kernel(&ip, sizeof(ip), (void *)address + 4);
488485

489-
u8 *is_blocked = blocked_ips.lookup(&ip);
490-
if (!is_blocked)
486+
u8 *mode = blocked_ips.lookup(&ip);
487+
if (!mode)
491488
return 0;
492489

493490
char comm[TASK_COMM_LEN];
@@ -508,9 +505,11 @@ LSM_PROBE(socket_connect, struct socket *sock, struct sockaddr *address,
508505
evt.blocked_ip = ip;
509506
const char msg[] = "NETWORK_CONNECT";
510507
__builtin_memcpy((void *)evt.filename, msg, sizeof(msg));
511-
evt.action = 1;
508+
evt.action = (*mode == 2) ? 1 : 6;
512509
events.perf_submit(sock, &evt, sizeof(evt));
513-
return -1; /* -EPERM */
510+
if (*mode == 2)
511+
return -1; /* -EPERM */
512+
return 0;
514513
}
515514
#endif
516515

@@ -539,7 +538,7 @@ TRACEPOINT_PROBE(syscalls, sys_enter_connect) {
539538
if (!is_blocked) {
540539
#else
541540
/* Fallback: tracepoint + SIGKILL when LSM unavailable */
542-
if (_check_blocked_ip(args, pid, ip, 1))
541+
if (_check_blocked_ip(args, pid, ip))
543542
return 0;
544543
{
545544
#endif
@@ -584,7 +583,7 @@ TRACEPOINT_PROBE(syscalls, sys_enter_sendto) {
584583
if (family == 2) { /* AF_INET */
585584
u32 ip = 0;
586585
bpf_probe_read_user(&ip, sizeof(ip), (void *)addr + 4);
587-
_check_blocked_ip(args, pid, ip, 1);
586+
_check_blocked_ip(args, pid, ip);
588587
}
589588

590589
return 0;

0 commit comments

Comments
 (0)