fix(skills): provide safe workspace-specific malicious skill remediation (#118969)

This commit is contained in:
Peter Steinberger
2026-08-03 14:39:02 -07:00
committed by GitHub
parent 7daf990688
commit 108cec388e
6 changed files with 231 additions and 59 deletions
+26 -3
View File
@@ -1,8 +1,9 @@
---
summary: "ClawHub CLI entry points for discovering, installing, publishing, and verifying OpenClaw skills and plugins."
summary: "ClawHub CLI entry points for discovering, installing, removing, publishing, and verifying OpenClaw skills and plugins."
read_when:
- You want to use ClawHub from the command line
- You want to install ClawHub skills or plugins through OpenClaw
- You need to remove an installed ClawHub skill
- You want to publish ClawHub packages
title: "ClawHub CLI"
---
@@ -13,8 +14,8 @@ Two command-line surfaces talk to ClawHub:
- `openclaw skills` / `openclaw plugins` - discover, install, and update
packages for a local OpenClaw agent or Gateway.
- The standalone `clawhub` CLI - publisher workflows: login, publish, sync,
and transfer.
- The standalone `clawhub` CLI - remove installed skills and handle publisher
workflows including login, publish, sync, and transfer.
## Discover and install
@@ -60,6 +61,28 @@ enforces scan and force-install policy before returning a pinned commit.
- **Official ClawHub publishers/packages and bundled OpenClaw sources** skip
the trust prompt and security-verdict fetch entirely.
## Remove an installed skill
If the standalone ClawHub CLI is not already installed, install it explicitly:
```bash
npm i -g clawhub
clawhub uninstall @owner/my-skill
```
The command asks for confirmation, then removes the installed skill directory
and its ClawHub lockfile entry. Select the original agent workspace or shared
OpenClaw state directory when the installation is outside the current workdir:
```bash
clawhub --workdir /path/to/agent-workspace uninstall @owner/my-skill
clawhub --workdir ~/.openclaw uninstall @owner/my-skill
```
For a custom `OPENCLAW_STATE_DIR`, replace `~/.openclaw` with that configured
directory. See [Remove a ClawHub skill](/cli/skills#remove-a-clawhub-skill) for
workspace targeting and skill refresh behavior.
## Publish and maintain
Install the standalone CLI once, then log in:
+34
View File
@@ -3,6 +3,7 @@ summary: "CLI reference for `openclaw skills` (search/install/update/verify/list
read_when:
- You want to see which skills are available and ready to run
- You want to search ClawHub or install skills from ClawHub, Git, or local directories
- You need to remove an installed ClawHub skill
- You want to verify a ClawHub skill with ClawHub
- You want to debug missing binaries/env/config for skills
title: "Skills"
@@ -134,6 +135,39 @@ non-interactive command should continue after that review. Official ClawHub
skill publishers and bundled OpenClaw skill sources bypass this release-trust
prompt.
## Remove a ClawHub skill
Use the standalone ClawHub CLI to remove a ClawHub-tracked skill. If the CLI
is not installed, install it explicitly first:
```bash
npm i -g clawhub
clawhub uninstall @owner/my-skill
```
The CLI asks for confirmation before deleting the skill directory and its
`.clawhub/lock.json` entry. Use the installed skill's owner-qualified name or
bare slug, not its original `skills-sh:` reference.
Select the same root where the skill was installed: the agent workspace for an
agent-specific skill, or the OpenClaw state directory for a shared skill
installed with `--global`:
```bash
clawhub --workdir /path/to/agent-workspace uninstall @owner/my-skill
clawhub --workdir ~/.openclaw uninstall @owner/my-skill
```
If `OPENCLAW_STATE_DIR` is set, use that configured state directory for shared
skills instead:
```bash
clawhub --workdir "$OPENCLAW_STATE_DIR" uninstall @owner/my-skill
```
The default [skills watcher](/tools/skills#snapshots-and-refresh) picks up the
removal on the next agent turn. If watching is disabled, start a new session.
## Skill Workshop
`openclaw skills workshop` manages pending skill proposals in the selected
+31 -3
View File
@@ -1,13 +1,13 @@
---
name: clawhub
description: "Search ClawHub for skills when a requested capability is not already available; install, verify, update, publish, or sync skills."
description: "Search ClawHub for skills when a requested capability is not already available; install, verify, update, uninstall, publish, or sync skills."
---
# ClawHub
Use `openclaw skills` to discover and manage skills for the current OpenClaw
agent. Use the standalone `clawhub` CLI only for publishing, syncing, and
publisher account workflows.
agent. Use the standalone `clawhub` CLI to uninstall installed ClawHub skills
and for publishing, syncing, and publisher account workflows.
## Discover skills
@@ -38,6 +38,34 @@ openclaw skills update --all
Use `--global` with `install` or `update` to manage skills shared by all local
agents.
## Remove an installed skill
Obtain user approval before uninstalling. If the standalone ClawHub CLI is not
installed, install it explicitly:
```bash
npm i -g clawhub
clawhub uninstall @owner/my-skill
```
The CLI asks for confirmation before removing the skill and its lockfile entry.
Use the original agent workspace for agent-specific skills or the OpenClaw
state directory for skills installed with `--global`:
```bash
clawhub --workdir /path/to/agent-workspace uninstall @owner/my-skill
clawhub --workdir ~/.openclaw uninstall @owner/my-skill
```
If `OPENCLAW_STATE_DIR` is set, use its value instead of `~/.openclaw`:
```bash
clawhub --workdir "$OPENCLAW_STATE_DIR" uninstall @owner/my-skill
```
The default skills watcher refreshes the available skills on the next agent
turn. If watching is disabled, start a new session.
## Publish skills
Install the standalone ClawHub CLI for publisher workflows:
+13 -5
View File
@@ -4,6 +4,7 @@ import { stripAnsi, visibleWidth } from "../../packages/terminal-core/src/ansi.j
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { formatTerminalLink } from "../../packages/terminal-core/src/terminal-link.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import {
fetchClawHubPackageSecurity,
fetchClawHubSkillVerification,
@@ -63,11 +64,9 @@ type ClawHubInstallLogger = {
terminalLinks?: boolean;
};
type ClawHubTrustSubject = {
kind: "plugin" | "skill";
packageName: string;
ownerHandle?: string;
};
type ClawHubTrustSubject =
| { kind: "plugin"; packageName: string }
| { kind: "skill"; packageName: string; workspaceDir: string; ownerHandle?: string };
type ClawHubSkillSecurityLinks = {
subject: string;
@@ -536,6 +535,15 @@ function formatClawHubTrustWarning(params: {
params.assessment.disposition,
),
formatClawHubRawLinks({ subject: params.subject, links }),
...(params.subject.kind === "skill" && malicious && params.mode === "update"
? [
`Remove installed skill: clawhub --workdir ${quoteCliArg(
sanitizeTerminalText(params.subject.workspaceDir),
)} uninstall ${quoteCliArg(
sanitizeTerminalText(formatClawHubSubjectPackageName(params.subject)),
)}`,
]
: []),
].join("\n");
}
if (params.assessment.disposition === "review-required") {
@@ -430,6 +430,7 @@ export async function ensureClawHubSkillTrustAcknowledged(
subject: {
kind: "skill",
packageName: params.slug,
workspaceDir: params.workspaceDir,
...(params.ownerHandle ? { ownerHandle: params.ownerHandle } : {}),
},
version: params.version,
+126 -48
View File
@@ -1,4 +1,5 @@
// ClawHub lifecycle tests cover registry metadata lookup and error handling.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
@@ -1935,58 +1936,135 @@ describe("skills-clawhub", () => {
});
});
it("explains that a malicious skill update will not be downloaded", async () => {
const workspaceDir = await tempDirs.make("openclaw-skill-malicious-update-");
const warnings: string[] = [];
await writeClawHubOriginFixture({
workspaceDir,
slug: "agentreceipt",
installedVersion: "0.9.0",
});
fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({
schema: "clawhub.skill.security-verdicts.v1",
items: [
{
ok: false,
decision: "fail",
reasons: ["scan:malicious"],
requestedSlug: "agentreceipt",
requestedVersion: "1.0.0",
slug: "agentreceipt",
version: "1.0.0",
security: {
status: "malicious",
passed: false,
it.each([
{
ownerHandle: undefined,
skillRef: "agentreceipt",
shellArg: "agentreceipt",
workspaceName: "agent-workspace",
},
{
ownerHandle: "acme",
skillRef: "@acme/agentreceipt",
shellArg: "@acme/agentreceipt",
workspaceName: "different agent workspace",
},
{
ownerHandle: undefined,
skillRef: "agentreceipt",
shellArg: "agentreceipt",
workspaceName: ".openclaw",
},
{
ownerHandle: undefined,
skillRef: "a;printf X",
shellArg: "'a;printf X'",
workspaceName: "shared state;printf PWN",
},
{
ownerHandle: undefined,
skillRef: "b$(printf X)",
shellArg: "'b$(printf X)'",
workspaceName: "agent$(printf PWN)",
},
{
ownerHandle: undefined,
skillRef: "c`printf X`",
shellArg: "'c`printf X`'",
workspaceName: "agent`printf PWN`",
},
{
ownerHandle: undefined,
skillRef: "white space",
shellArg: "'white space'",
workspaceName: "custom state directory",
},
{
ownerHandle: undefined,
skillRef: "d'$(printf X)",
shellArg: "'d'\\''$(printf X)'",
workspaceName: "agent's $(printf PWN) state",
},
])(
"explains that a malicious skill update will not be downloaded ($skillRef)",
async ({ ownerHandle, skillRef, shellArg, workspaceName }) => {
const tempRoot = await tempDirs.make("openclaw-skill-malicious-update-");
const workspaceDir = path.join(tempRoot, workspaceName);
const warnings: string[] = [];
const slug = ownerHandle ? skillRef.slice(skillRef.indexOf("/") + 1) : skillRef;
await writeClawHubOriginFixture({
workspaceDir,
slug,
ownerHandle,
installedVersion: "0.9.0",
});
fetchClawHubSkillSecurityVerdictsMock.mockResolvedValueOnce({
schema: "clawhub.skill.security-verdicts.v1",
items: [
{
ok: false,
decision: "fail",
reasons: ["scan:malicious"],
requestedSlug: slug,
requestedVersion: "1.0.0",
slug,
version: "1.0.0",
...(ownerHandle ? { publisherHandle: ownerHandle } : {}),
security: {
status: "malicious",
passed: false,
},
},
],
});
const results = await updateSkillsFromClawHub({
workspaceDir,
slug: skillRef,
logger: {
warn: (message) => warnings.push(message),
},
],
});
});
const results = await updateSkillsFromClawHub({
workspaceDir,
slug: "agentreceipt",
logger: {
warn: (message) => warnings.push(message),
},
});
expect(results).toEqual([
expect.objectContaining({
ok: false,
code: "clawhub_download_blocked",
error: "ClawHub blocked this release; update was not started.",
}),
]);
expect(warnings.join("\n")).toContain(
"Latest skill version is marked malicious; OpenClaw will not download it.",
);
const workspaceArg = /^[A-Za-z0-9_/:=.,@%+-]+$/.test(workspaceDir)
? workspaceDir
: `'${workspaceDir.replaceAll("'", "'\\''")}'`;
const uninstallCommand = `clawhub --workdir ${workspaceArg} uninstall ${shellArg}`;
const actionLine = expectDefined(
warnings
.join("\n")
.split("\n")
.find((line) => line.startsWith("Remove installed skill: ")),
"malicious skill warning remediation",
);
expect(actionLine).toBe(`Remove installed skill: ${uninstallCommand}`);
expect(warnings.join("\n")).toContain("independently reviewed it.");
expect(warnings.join("\n")).not.toContain("Choose a different version");
expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled();
expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled();
expect(results).toEqual([
expect.objectContaining({
ok: false,
code: "clawhub_download_blocked",
error: "ClawHub blocked this release; update was not started.",
}),
]);
expect(warnings.join("\n")).toContain(
"Latest skill version is marked malicious; OpenClaw will not download it.",
);
expect(warnings.join("\n")).toContain(
"Uninstall the installed skill unless you have independently reviewed it.",
);
expect(warnings.join("\n")).not.toContain("Choose a different version");
expect(downloadClawHubSkillArchiveUrlMock).not.toHaveBeenCalled();
expect(downloadClawHubSkillArchiveMock).not.toHaveBeenCalled();
});
const shellResult = spawnSync(
"sh",
[
"-c",
`clawhub() { printf '%s\\n' "$@"; }\n${actionLine.slice("Remove installed skill: ".length)}`,
],
{ encoding: "utf8" },
);
expect(shellResult.status).toBe(0);
expect(shellResult.stdout).toBe(`--workdir\n${workspaceDir}\nuninstall\n${skillRef}\n`);
},
);
it("updates owner-qualified ClawHub skills when the requested owner matches tracking", async () => {
const workspaceDir = await tempDirs.make("openclaw-owner-update-request-");