Skip to content

Commit aadff41

Browse files
fix(agent-skills): address review safety gaps
Signed-off-by: Pleasurecruise <3196812536@qq.com>
1 parent 8cef7a0 commit aadff41

18 files changed

Lines changed: 717 additions & 360 deletions

File tree

resources/skills/find-skills/SKILL.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ Cherry gives you two built-in tools for skills — use them, and do NOT shell ou
2727
opaque `install_source` value.
2828
- **`install_skill(install_source)`** — install ONE skill into Cherry's managed
2929
library and enable it for this agent. Cherry clones the repo, installs just that one skill,
30-
and registers it in a single deterministic step. Permission handling follows the active Claude
31-
permission mode (Step 6).
30+
and registers it in a single deterministic step. Cherry requires explicit user approval
31+
independently of the active Claude permission mode (Step 6).
3232

3333
**Browse skills at:** https://skills.sh/
3434

@@ -58,8 +58,9 @@ If the leaderboard doesn't cover the user's need, call the `search_skills` tool:
5858
- User asks "can you help me with PR reviews?" → `search_skills("pr review")`
5959
- User asks "I need to create a changelog" → `search_skills("changelog")`
6060

61-
Each result includes an opaque `install_source` — pass that exact value to
62-
`install_skill`.
61+
Each result includes an opaque `install_source`, source registry, review URL,
62+
install count, and available star count. Pass the exact `install_source` value
63+
to `install_skill`.
6364

6465
### Step 4: Verify Quality Before Recommending
6566

@@ -90,7 +91,7 @@ I can install it into Cherry's skill library for you — want me to go ahead?
9091
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
9192
```
9293

93-
### Step 6: Install (Uses the Active Permission Mode)
94+
### Step 6: Install (Requires Explicit Confirmation)
9495

9596
**⚠️ Security:** Skills are third-party code that runs with full agent
9697
permissions. A malicious skill could read, modify, or delete files on your
@@ -102,13 +103,12 @@ Before installing any skill:
102103
code and will run with full agent permissions.
103104
2. **Provide a review link** — the skills.sh page (or source repository) so
104105
the user can review the skill's SKILL.md and any scripts it contains.
105-
3. **Require install intent** — call `install_skill` only when the user asked to install the skill
106-
or accepted a presented option. A search-only request must not mutate the skill library.
106+
3. **Ask for explicit confirmation** — call `install_skill` only after the user accepts the
107+
security warning and review link. A search-only request must not mutate the skill library.
107108

108-
Once the user has expressed install intent, call `install_skill` with the exact `install_source`
109-
from the search result. Do not add another model-level confirmation step: Claude's active permission
110-
mode is the authority. Default and accept-edits modes may prompt through the SDK; bypass-permissions
111-
mode runs directly.
109+
Once the user confirms, call `install_skill` with the exact `install_source` from the search result.
110+
Cherry enforces the approval boundary even when the agent otherwise runs in bypass-permissions mode;
111+
channel and scheduled turns cannot install third-party skills.
112112

113113
- `install_skill("claude-plugins:vercel-labs/agent-skills/skills/react-best-practices")`
114114

resources/skills/skill-creator/SKILL.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ keeps in sync with the filesystem automatically — there is **no** install or r
1010
tool to call, and you should **ignore** any `package_skill.py` / `.skill` packaging
1111
steps mentioned later in this file (they apply to Claude Code / Claude.ai, not here).
1212

13-
**To create a new skill, write it straight into Cherry's managed skills directory:**
13+
**To create a new skill, write it into Cherry's authoring inbox:**
1414

1515
1. Resolve the directory once by running `echo "$CHERRY_STUDIO_SKILLS_DIR"` in Bash.
16-
That folder is Cherry's managed skill library.
16+
That folder is Cherry's staging inbox, not the executable skill library.
1717
2. Create `$CHERRY_STUDIO_SKILLS_DIR/<skill-folder-name>/` and write `SKILL.md` plus any
1818
supporting files (`scripts/`, `references/`, `assets/`) into it with your normal file
1919
tools.
20-
3. That's it. Cherry's skill sync detects the new directory, registers it in the catalog,
21-
and lists it in the app — no register step. You can re-edit the files in place at any
22-
time and the changes are picked up on the next sync.
20+
3. That's it. Cherry's skill sync validates and atomically publishes the staged directory,
21+
registers it in the catalog, and lists it in the app — no register step. If the folder
22+
collides with a builtin, marketplace, or differently-authored skill, Cherry keeps the
23+
draft in staging and refuses to overwrite the installed content.
2324

2425
Use a lowercase, hyphenated `<skill-folder-name>` (e.g. `my-cool-skill`). The `name:`
2526
field inside your `SKILL.md` frontmatter is the display name and may differ from the

src/main/ai/mcp/servers/__tests__/skills.test.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,16 @@ async function callTool(server: SkillsServerInstance, name: string, args: Record
2828
}
2929

3030
function mockMarketplace(skills: unknown[]) {
31-
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ skills }) })
31+
fetchMock.mockImplementation(async (url: string) => ({
32+
ok: true,
33+
status: 200,
34+
statusText: 'OK',
35+
json: async () => {
36+
if (url.startsWith('https://skills.sh/')) return { skills: [] }
37+
if (url.startsWith('https://clawhub.ai/')) return { results: [] }
38+
return { skills }
39+
}
40+
}))
3241
}
3342

3443
describe('SkillsServer', () => {
@@ -50,15 +59,85 @@ describe('SkillsServer', () => {
5059
namespace: 'vercel-labs',
5160
description: 'React perf',
5261
author: 'vercel',
62+
stars: 42,
5363
installs: 100,
54-
metadata: { repoOwner: 'vercel-labs', repoName: 'agent-skills', directoryPath: 'skills/react-best-practices' }
64+
sourceUrl: 'https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices',
65+
metadata: {
66+
repoOwner: 'vercel-labs',
67+
repoName: 'agent-skills',
68+
directoryPath: 'skills/react-best-practices'
69+
}
5570
}
5671
])
5772

5873
const result = await callTool(createServer(), 'search_skills', { query: 'react perf' })
74+
const payload = JSON.parse(
75+
result.content[0].text.slice(result.content[0].text.indexOf('['), result.content[0].text.lastIndexOf(']') + 1)
76+
)
5977

6078
expect(result.isError).toBeFalsy()
61-
expect(result.content[0].text).toContain('claude-plugins:vercel-labs/agent-skills/skills/react-best-practices')
79+
expect(payload).toEqual([
80+
expect.objectContaining({
81+
stars: 42,
82+
source_registry: 'claude-plugins.dev',
83+
source_url: 'https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices',
84+
install_source: 'claude-plugins:vercel-labs/agent-skills/skills/react-best-practices'
85+
})
86+
])
87+
})
88+
89+
it('searches every marketplace supported by the renderer', async () => {
90+
fetchMock.mockImplementation(async (url: string) => ({
91+
ok: true,
92+
status: 200,
93+
statusText: 'OK',
94+
json: async () => {
95+
if (url.startsWith('https://skills.sh/')) {
96+
return {
97+
query: 'developer tools',
98+
count: 1,
99+
skills: [
100+
{
101+
id: 'owner/repo/web-search',
102+
skillId: 'web-search',
103+
name: 'Web Search',
104+
source: 'owner/repo',
105+
installs: 12
106+
}
107+
]
108+
}
109+
}
110+
if (url.startsWith('https://clawhub.ai/')) {
111+
return {
112+
results: [
113+
{
114+
score: 1,
115+
slug: 'code-review',
116+
displayName: 'Code Review',
117+
summary: 'Review code',
118+
version: '1.0.0',
119+
updatedAt: 1,
120+
ownerHandle: 'owner'
121+
}
122+
]
123+
}
124+
}
125+
return { skills: [] }
126+
}
127+
}))
128+
129+
const result = await callTool(createServer(), 'search_skills', { query: 'developer tools' })
130+
const payload = JSON.parse(
131+
result.content[0].text.slice(result.content[0].text.indexOf('['), result.content[0].text.lastIndexOf(']') + 1)
132+
)
133+
134+
expect(fetchMock).toHaveBeenCalledTimes(3)
135+
expect(payload).toEqual(
136+
expect.arrayContaining([
137+
expect.objectContaining({ source_registry: 'skills.sh', install_source: 'skills.sh:owner/repo/web-search' }),
138+
expect.objectContaining({ source_registry: 'clawhub.ai', install_source: 'clawhub:code-review' })
139+
])
140+
)
62141
})
63142

64143
it('builds install_source from directoryPath, not the display name (regression)', async () => {

src/main/ai/mcp/servers/skills.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { skillService } from '@main/ai/skills/SkillService'
33
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
44
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
55
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'
6-
import { normalizeClaudePlugins } from '@shared/utils/skillMarketplace'
6+
import { searchSkillMarketplaces } from '@shared/utils/skillMarketplace'
77
import { net } from 'electron'
88

99
const logger = loggerService.withContext('McpServer:Skills')
1010

11-
const MARKETPLACE_BASE_URL = 'https://claude-plugins.dev'
11+
const REQUEST_TIMEOUT_MS = 15_000
1212

1313
const SEARCH_TOOL: Tool = {
1414
name: 'search_skills',
@@ -29,7 +29,7 @@ const SEARCH_TOOL: Tool = {
2929
const INSTALL_TOOL: Tool = {
3030
name: 'install_skill',
3131
description:
32-
"Install ONE marketplace skill into Cherry Studio's managed library and enable it for the current agent. Pass the exact `install_source` string from a search_skills result — do NOT construct it yourself, and do NOT run `npx skills add`, `git clone`, or any shell command. Cherry clones the repo, installs just that single skill, and registers it. Call this only when the user intends to install the skill; the active Claude permission mode controls whether execution prompts or runs directly.",
32+
"Install ONE marketplace skill into Cherry Studio's managed library and enable it for the current agent. Pass the exact `install_source` string from a search_skills result — do NOT construct it yourself, and do NOT run `npx skills add`, `git clone`, or any shell command. Cherry clones the repo, installs just that single skill, and registers it. Call this only after the user explicitly confirms the installation; Cherry enforces that confirmation independently of the agent permission mode.",
3333
inputSchema: {
3434
type: 'object',
3535
properties: {
@@ -107,19 +107,17 @@ class SkillsServer {
107107
const query = args.query
108108
if (!query) throw new McpError(ErrorCode.InvalidParams, "'query' is required for search_skills")
109109

110-
const url = new URL(`${MARKETPLACE_BASE_URL}/api/skills`)
111-
url.searchParams.set('q', query.replace(/[-_]+/g, ' ').trim())
112-
url.searchParams.set('limit', '20')
113-
url.searchParams.set('offset', '0')
114-
115-
const response = await net.fetch(url.toString(), { method: 'GET' })
116-
if (!response.ok) {
117-
throw new Error(`Marketplace API returned ${response.status}: ${response.statusText}`)
118-
}
119-
120-
// Shared normalizer: builds install_source from the real directoryPath and drops entries whose
121-
// install target can't be resolved reliably (so we never hand back an ambiguous one).
122-
const results = normalizeClaudePlugins(await response.json())
110+
const results = await searchSkillMarketplaces(
111+
query.replace(/[-_]+/g, ' ').trim(),
112+
(url) => this.fetchMarketplaceJson(url),
113+
(source, error) => {
114+
logger.warn('Skill marketplace search source failed', {
115+
agentId: this.agentId,
116+
source,
117+
error: error instanceof Error ? error.message : String(error)
118+
})
119+
}
120+
)
123121

124122
if (results.length === 0) {
125123
return { content: [{ type: 'text' as const, text: `No installable skills found for "${query}".` }] }
@@ -129,7 +127,10 @@ class SkillsServer {
129127
name: r.name,
130128
description: r.description,
131129
author: r.author,
130+
stars: r.stars,
132131
installs: r.downloads,
132+
source_registry: r.sourceRegistry,
133+
source_url: r.sourceUrl,
133134
install_source: r.installSource
134135
}))
135136

@@ -144,6 +145,20 @@ class SkillsServer {
144145
}
145146
}
146147

148+
private async fetchMarketplaceJson(url: string): Promise<unknown> {
149+
const controller = new AbortController()
150+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
151+
try {
152+
const response = await net.fetch(url, { method: 'GET', signal: controller.signal })
153+
if (!response.ok) {
154+
throw new Error(`Marketplace API returned ${response.status}: ${response.statusText}`)
155+
}
156+
return response.json()
157+
} finally {
158+
clearTimeout(timer)
159+
}
160+
}
161+
147162
private async installSkill(args: Record<string, string | undefined>) {
148163
const installSource = args.install_source
149164
if (!installSource) {

src/main/ai/runtime/claudeCode/__tests__/settingsBuilder.test.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -681,15 +681,15 @@ describe('buildClaudeCodeSessionSettings', () => {
681681
})
682682

683683
it.each([
684-
{ permissionMode: 'default', headless: false, shouldDeny: false },
685-
{ permissionMode: 'acceptEdits', headless: false, shouldDeny: false },
686-
{ permissionMode: 'bypassPermissions', headless: false, shouldDeny: false },
687-
{ permissionMode: 'default', headless: true, shouldDeny: true },
688-
{ permissionMode: 'acceptEdits', headless: true, shouldDeny: true },
689-
{ permissionMode: 'bypassPermissions', headless: true, shouldDeny: false }
684+
{ permissionMode: 'default', headless: false, expectedDecision: 'ask' },
685+
{ permissionMode: 'acceptEdits', headless: false, expectedDecision: 'ask' },
686+
{ permissionMode: 'bypassPermissions', headless: false, expectedDecision: 'ask' },
687+
{ permissionMode: 'default', headless: true, expectedDecision: 'deny' },
688+
{ permissionMode: 'acceptEdits', headless: true, expectedDecision: 'deny' },
689+
{ permissionMode: 'bypassPermissions', headless: true, expectedDecision: 'deny' }
690690
])(
691-
'applies SDK permission semantics to skill install ($permissionMode, headless=$headless)',
692-
async ({ permissionMode, headless, shouldDeny }) => {
691+
'requires an explicit skill-install decision ($permissionMode, headless=$headless)',
692+
async ({ permissionMode, headless, expectedDecision }) => {
693693
const isCurrentTurnHeadless = vi.fn(() => headless)
694694
mocks.applicationGet.mockImplementation((name: string) => {
695695
if (name === 'PreferenceService') return { get: vi.fn(() => undefined) }
@@ -724,16 +724,15 @@ describe('buildClaudeCodeSessionSettings', () => {
724724
)
725725
)
726726
)
727-
const denial = expect.objectContaining({
728-
hookSpecificOutput: expect.objectContaining({ permissionDecision: 'deny' })
727+
const decision = expect.objectContaining({
728+
hookSpecificOutput: expect.objectContaining({ permissionDecision: expectedDecision })
729729
})
730730

731-
if (shouldDeny) expect(results).toContainEqual(denial)
732-
else expect(results).not.toContainEqual(denial)
731+
expect(results).toContainEqual(decision)
733732
}
734733
)
735734

736-
it('uses the live permission mode when a warm session switches to bypassPermissions', async () => {
735+
it('still denies headless installation when a warm session switches to bypassPermissions', async () => {
737736
let permissionMode = 'default'
738737
const isCurrentTurnHeadless = vi.fn(() => true)
739738
mocks.applicationGet.mockImplementation((name: string) => {
@@ -771,7 +770,7 @@ describe('buildClaudeCodeSessionSettings', () => {
771770
)
772771
)
773772

774-
expect(results).not.toContainEqual(
773+
expect(results).toContainEqual(
775774
expect.objectContaining({ hookSpecificOutput: expect.objectContaining({ permissionDecision: 'deny' }) })
776775
)
777776
})
@@ -1294,9 +1293,8 @@ describe('buildClaudeCodeSessionSettings', () => {
12941293
expect(settings.env!.CLAUDE_CODE_USE_VERTEX).toBe('0')
12951294
// Non-mac (platform mock has no isMac): reuse the user's real config dir from the login shell.
12961295
expect(settings.env!.CLAUDE_CONFIG_DIR).toBe('/home/me/.claude')
1297-
// The Cherry skill library path is injected unconditionally, so it survives external-CLI
1298-
// stripping — skill authoring keeps a stable target even when CLAUDE_CONFIG_DIR is redirected.
1299-
expect(settings.env!.CHERRY_STUDIO_SKILLS_DIR).toBe('/app/feature.agents.skills')
1296+
// The authoring inbox is injected unconditionally, so it survives external-CLI stripping.
1297+
expect(settings.env!.CHERRY_STUDIO_SKILLS_DIR).toBe('/app/feature.agents.skills.authoring')
13001298
})
13011299

13021300
it('falls back CLAUDE_CONFIG_DIR to ~/.claude when the shell does not set it', async () => {
@@ -1335,9 +1333,8 @@ describe('buildClaudeCodeSessionSettings', () => {
13351333
)
13361334

13371335
expect(settings.env).not.toHaveProperty('CLAUDE_CONFIG_DIR')
1338-
// CLAUDE_CONFIG_DIR is dropped on macOS login, but the Cherry skill library path stays
1339-
// injected, so skill authoring still resolves to a stable, Cherry-owned directory.
1340-
expect(settings.env!.CHERRY_STUDIO_SKILLS_DIR).toBe('/app/feature.agents.skills')
1336+
// CLAUDE_CONFIG_DIR is dropped on macOS login, but the Cherry authoring inbox stays injected.
1337+
expect(settings.env!.CHERRY_STUDIO_SKILLS_DIR).toBe('/app/feature.agents.skills.authoring')
13411338
})
13421339

13431340
it('blocks a reserved agent env_var override but passes through non-reserved keys', async () => {

src/main/ai/runtime/claudeCode/settingsBuilder.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ async function buildEnvironment(provider: Provider, agent: AgentEntity): Promise
582582
ENABLE_TOOL_SEARCH: 'auto',
583583
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
584584
CHERRY_STUDIO_BUN_PATH: bunPath,
585-
CHERRY_STUDIO_SKILLS_DIR: application.getPath('feature.agents.skills'),
585+
CHERRY_STUDIO_SKILLS_DIR: application.getPath('feature.agents.skills.authoring'),
586586
...(customGitBashPath ? { CLAUDE_CODE_GIT_BASH_PATH: customGitBashPath } : {})
587587
}
588588

@@ -878,23 +878,21 @@ async function buildToolPermissions(
878878
}
879879
}
880880

881-
// Installing a skill requires the same permission handling as any other mutating tool. Interactive
882-
// turns defer to the SDK: default / acceptEdits prompt through canUseTool, while bypassPermissions
883-
// runs directly. A headless turn has no responder, so deny only when its live permission mode still
884-
// requires approval. Resolve the mode from the session snapshot so a warm connection observes a
885-
// live permission-mode update instead of the agent config captured when these hooks were built.
881+
// Third-party skills execute with the agent's full permissions, so installation always crosses an
882+
// explicit user-approval boundary independent of the agent's general permission mode. Headless
883+
// turns cannot answer that prompt and therefore fail closed.
886884
const headlessSkillInstallHook: HookCallback = async (input): Promise<HookJSONOutput> => {
887885
if (!input || input.hook_event_name !== 'PreToolUse') return {}
888886
const toolName = String((input as Record<string, unknown>).tool_name ?? '')
889887
if (toolName !== 'mcp__skills__install_skill') return {}
890-
if (getToolPolicySnapshot(session.id)?.getPermissionMode() === 'bypassPermissions') return {}
891-
if (!application.get('AgentSessionRuntimeService').isCurrentTurnHeadless(session.id)) return {}
888+
const headless = application.get('AgentSessionRuntimeService').isCurrentTurnHeadless(session.id)
892889
return {
893890
hookSpecificOutput: {
894891
hookEventName: 'PreToolUse',
895-
permissionDecision: 'deny',
896-
permissionDecisionReason:
897-
'This channel or scheduled turn cannot approve a skill installation. Use bypassPermissions for unattended installation, or install it from an interactive turn.'
892+
permissionDecision: headless ? 'deny' : 'ask',
893+
permissionDecisionReason: headless
894+
? 'Channel and scheduled turns cannot approve third-party skill installation. Install it from an interactive turn.'
895+
: 'Installing this third-party skill will let it run with the agent permissions. Confirm the installation.'
898896
}
899897
}
900898
}

0 commit comments

Comments
 (0)