mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 03:04:29 -04:00
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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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>
|
||||
|
||||
${
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user