feat(security): add client-side skill security enforcement

Add a capability-based security model for community skills, inspired by
how mobile and Apple ecosystem apps declare capabilities upfront. This is
not a silver bullet for prompt injection, but it's a significant step up
from the status quo and encourages responsible developer practices by
making capability requirements explicit and visible.

Runtime enforcement for community skills installed from ClawHub:

- Capability declarations (shell, filesystem, network, browser, sessions)
  parsed from SKILL.md frontmatter and enforced at tool-call time
- Static SKILL.md scanner detecting prompt injection patterns, suspicious
  constructs, and capability mismatches
- Global skill security context tracking loaded community skills and
  their aggregate capabilities
- Before-tool-call enforcement gate blocking undeclared tool usage
- Command-dispatch capability check preventing shell/filesystem access
  without explicit declaration
- Trust tier classification (builtin/community/local) — only community
  skills are subject to enforcement
- System prompt trust context warning for skills with scan warnings or
  missing capability declarations
- CLI: `skills list -v`, `skills info`, `skills check` now surface
  capabilities, scan results, and security status
- TUI security log panel for skill enforcement events
- Docs updated across 7 files covering the full security model

Companion PR: openclaw/clawhub (capability visibility + UI badges)
This commit is contained in:
theonejvo
2026-02-17 02:26:41 +11:00
parent 9a344da298
commit b3c52c4145
28 changed files with 1478 additions and 137 deletions

View File

@@ -1066,6 +1066,12 @@
border-color: var(--danger-subtle);
}
.log-chip.active {
color: var(--info);
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.1);
}
.log-subsystem {
color: var(--muted);
font-family: var(--mono);

View File

@@ -929,12 +929,14 @@ export function renderApp(state: AppViewState) {
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
categoryFilter: state.logsCategoryFilter,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onCategoryToggle: (category) => (state.logsCategoryFilter = category),
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),

View File

@@ -212,6 +212,7 @@ export type AppViewState = {
logsEntries: LogEntry[];
logsFilterText: string;
logsLevelFilters: Record<LogLevel, boolean>;
logsCategoryFilter: string | null;
logsAutoFollow: boolean;
logsTruncated: boolean;
logsCursor: number | null;

View File

@@ -316,6 +316,7 @@ export class OpenClawApp extends LitElement {
@state() logsLevelFilters: Record<LogLevel, boolean> = {
...DEFAULT_LOG_LEVEL_FILTERS,
};
@state() logsCategoryFilter: string | null = null;
@state() logsAutoFollow = true;
@state() logsTruncated = false;
@state() logsCursor: number | null = null;

View File

@@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { LogEntry, LogLevel } from "../types.ts";
import type { LogCategory, LogEntry, LogLevel } from "../types.ts";
export type LogsState = {
client: GatewayBrowserClient | null;
@@ -75,11 +75,30 @@ export function parseLogLine(line: string): LogEntry {
}
let message: string | null = null;
if (typeof obj["1"] === "string") {
let category: LogCategory | null = null;
// tslog puts metadata object in "1" and message in "2" when meta is provided
const metaObj =
obj["1"] && typeof obj["1"] === "object" && !Array.isArray(obj["1"])
? (obj["1"] as Record<string, unknown>)
: null;
if (metaObj) {
// Message is in "2" when meta object occupies "1"
if (typeof obj["2"] === "string") {
message = obj["2"];
}
if (typeof metaObj.category === "string") {
category = metaObj.category as LogCategory;
}
} else if (typeof obj["1"] === "string") {
message = obj["1"];
} else if (!contextObj && typeof obj["0"] === "string") {
}
if (!message && !contextObj && typeof obj["0"] === "string") {
message = obj["0"];
} else if (typeof obj.message === "string") {
}
if (!message && typeof obj.message === "string") {
message = obj.message;
}
@@ -90,6 +109,7 @@ export function parseLogLine(line: string): LogEntry {
subsystem,
message: message ?? line,
meta: meta ?? undefined,
category,
};
} catch {
return { raw: line, message: line };

View File

@@ -513,6 +513,13 @@ export type SkillInstallOption = {
bins: string[];
};
export type SkillCapability = "shell" | "filesystem" | "network" | "browser" | "sessions";
export type SkillScanResult = {
severity: "clean" | "info" | "warn" | "critical";
findings: string[];
};
export type SkillStatusEntry = {
name: string;
description: string;
@@ -542,6 +549,8 @@ export type SkillStatusEntry = {
};
configChecks: SkillsStatusConfigCheck[];
install: SkillInstallOption[];
capabilities: SkillCapability[];
scanResult?: SkillScanResult;
};
export type SkillStatusReport = {
@@ -556,6 +565,8 @@ export type HealthSnapshot = Record<string, unknown>;
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogCategory = "security";
export type LogEntry = {
raw: string;
time?: string | null;
@@ -563,4 +574,5 @@ export type LogEntry = {
subsystem?: string | null;
message?: string | null;
meta?: Record<string, unknown> | null;
category?: LogCategory | null;
};

View File

@@ -14,6 +14,7 @@ import { groupSkills } from "./skills-grouping.ts";
import {
computeSkillMissing,
computeSkillReasons,
renderCapabilityChips,
renderSkillStatusChips,
} from "./skills-shared.ts";
@@ -449,6 +450,7 @@ function renderAgentSkillRow(
<div class="list-title">${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}</div>
<div class="list-sub">${skill.description}</div>
${renderSkillStatusChips({ skill })}
${renderCapabilityChips(skill.capabilities)}
${
missing.length > 0
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`

View File

@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import type { LogEntry, LogLevel } from "../types.ts";
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
const CATEGORY_FILTERS = [{ id: "security", label: "Security" }] as const;
export type LogsProps = {
loading: boolean;
@@ -10,10 +11,12 @@ export type LogsProps = {
entries: LogEntry[];
filterText: string;
levelFilters: Record<LogLevel, boolean>;
categoryFilter: string | null;
autoFollow: boolean;
truncated: boolean;
onFilterTextChange: (next: string) => void;
onLevelToggle: (level: LogLevel, enabled: boolean) => void;
onCategoryToggle: (category: string | null) => void;
onToggleAutoFollow: (next: boolean) => void;
onRefresh: () => void;
onExport: (lines: string[], label: string) => void;
@@ -45,13 +48,17 @@ function matchesFilter(entry: LogEntry, needle: string) {
export function renderLogs(props: LogsProps) {
const needle = props.filterText.trim().toLowerCase();
const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]);
const categoryFiltered = props.categoryFilter !== null;
const filtered = props.entries.filter((entry) => {
if (entry.level && !props.levelFilters[entry.level]) {
return false;
}
if (categoryFiltered && entry.category !== props.categoryFilter) {
return false;
}
return matchesFilter(entry, needle);
});
const exportLabel = needle || levelFiltered ? "filtered" : "visible";
const exportLabel = needle || levelFiltered || categoryFiltered ? "filtered" : "visible";
return html`
<section class="card">
@@ -112,6 +119,20 @@ export function renderLogs(props: LogsProps) {
</label>
`,
)}
<span style="border-left: 1px solid var(--border); margin: 0 4px;"></span>
${CATEGORY_FILTERS.map(
(cat) => html`
<label class="chip log-chip ${props.categoryFilter === cat.id ? "active" : ""}">
<input
type="checkbox"
.checked=${props.categoryFilter === cat.id}
@change=${() =>
props.onCategoryToggle(props.categoryFilter === cat.id ? null : cat.id)}
/>
<span>${cat.label}</span>
</label>
`,
)}
</div>
${

View File

@@ -1,5 +1,13 @@
import { html, nothing } from "lit";
import type { SkillStatusEntry } from "../types.ts";
import type { SkillCapability, SkillStatusEntry } from "../types.ts";
const CAPABILITY_LABELS: Record<SkillCapability, { icon: string; label: string }> = {
shell: { icon: ">_", label: "Shell" },
filesystem: { icon: "fs", label: "Filesystem" },
network: { icon: "net", label: "Network" },
browser: { icon: "www", label: "Browser" },
sessions: { icon: "ses", label: "Sessions" },
};
export function computeSkillMissing(skill: SkillStatusEntry): string[] {
return [
@@ -21,6 +29,41 @@ export function computeSkillReasons(skill: SkillStatusEntry): string[] {
return reasons;
}
export function renderCapabilityChips(capabilities: SkillCapability[]) {
if (!capabilities || capabilities.length === 0) {
return nothing;
}
return html`
<div class="chip-row" style="margin-top: 6px;">
${capabilities.map((cap) => {
const info = CAPABILITY_LABELS[cap];
const isHighRisk = cap === "shell" || cap === "sessions";
return html`
<span class="chip ${isHighRisk ? "chip-warn" : ""}" title="${info?.label ?? cap}">
${info?.icon ?? cap} ${info?.label ?? cap}
</span>
`;
})}
</div>
`;
}
export function renderScanBadge(scanResult?: { severity: string; findings: string[] }) {
if (!scanResult) {
return nothing;
}
switch (scanResult.severity) {
case "critical":
return html`<span class="chip chip-danger" title="${scanResult.findings.join("; ")}">✗ blocked</span>`;
case "warn":
return html`<span class="chip chip-warn" title="${scanResult.findings.join("; ")}">⚠ warning</span>`;
case "info":
return html`<span class="chip" title="${scanResult.findings.join("; ")}"> notice</span>`;
default:
return nothing;
}
}
export function renderSkillStatusChips(params: {
skill: SkillStatusEntry;
showBundledBadge?: boolean;
@@ -47,6 +90,7 @@ export function renderSkillStatusChips(params: {
`
: nothing
}
${renderScanBadge(skill.scanResult)}
</div>
`;
}

View File

@@ -6,6 +6,7 @@ import { groupSkills } from "./skills-grouping.ts";
import {
computeSkillMissing,
computeSkillReasons,
renderCapabilityChips,
renderSkillStatusChips,
} from "./skills-shared.ts";
@@ -109,6 +110,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
${renderSkillStatusChips({ skill, showBundledBadge })}
${renderCapabilityChips(skill.capabilities)}
${
missing.length > 0
? html`