Skip to content

Commit 978d91c

Browse files
committed
feat(skills): reject html comments and links
1 parent 35492b2 commit 978d91c

File tree

3 files changed

+333
-72
lines changed

3 files changed

+333
-72
lines changed

src/commands/skill.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { join } from "node:path";
99
import { Command } from "commander";
1010
import { resolveShakaHome } from "../domain/config";
1111
import { loadManifest } from "../domain/skills-manifest";
12-
import { type ScanResult, installSkill } from "../services/skill-install-service";
12+
import {
13+
InstallCancelledError,
14+
type SecurityReport,
15+
installSkill,
16+
} from "../services/skill-install-service";
1317
import { removeSkill } from "../services/skill-remove-service";
1418
import { getProviderByName } from "../services/skill-source";
1519
import { type UpdateResult, updateAllSkills, updateSkill } from "../services/skill-update-service";
@@ -30,8 +34,7 @@ export function createSkillCommand(): Command {
3034
.command("install")
3135
.description("Install a skill (auto-detects source)")
3236
.argument("<source>", "Skill source (user/repo, URL, or clawdhub slug)")
33-
.option("--force", "Skip security scan prompt", false)
34-
.option("--safe-only", "Abort if non-text files found", false)
37+
.option("--yolo", "Skip security checks and install without confirmation", false)
3538
.option("--github", "Force GitHub provider")
3639
.option("--clawdhub", "Force Clawdhub provider")
3740
.action(handleInstall);
@@ -55,38 +58,54 @@ export function createSkillCommand(): Command {
5558

5659
async function handleInstall(
5760
source: string,
58-
opts: { force: boolean; safeOnly: boolean; github?: boolean; clawdhub?: boolean },
61+
opts: { yolo: boolean; github?: boolean; clawdhub?: boolean },
5962
): Promise<void> {
6063
const shakaHome = getShakaHome();
6164

6265
// Resolve provider override from flags
6366
const providerOverride = resolveProviderFlag(opts);
6467
if (providerOverride && !providerOverride.ok) {
65-
console.error(` ${providerOverride.error.message}`);
68+
console.error(`\u2717 ${providerOverride.error.message}`);
6669
process.exit(1);
6770
}
6871

6972
console.log(`Installing skill from ${source}...`);
7073

7174
const result = await installSkill(shakaHome, source, {
72-
force: opts.force,
73-
safeOnly: opts.safeOnly,
75+
yolo: opts.yolo,
7476
provider: providerOverride?.ok ? providerOverride.value : undefined,
75-
confirm: async (scan) => {
76-
console.log(formatScanWarning(scan));
77-
process.stdout.write("\nProceed with installation? [y/N] ");
77+
onSecurityCheck: async (report, skillName) => {
78+
console.log(formatSecurityReport(report));
79+
80+
if (!report.allPassed) {
81+
const failed = report.checks.filter((c) => !c.passed);
82+
for (const check of failed) {
83+
console.log(`\n\u{1F6A8} ${check.failureMessage}`);
84+
}
85+
console.log(
86+
`Run \`shaka skill install ${source} --yolo\` to skip checks and install anyway`,
87+
);
88+
return false;
89+
}
90+
91+
console.log(
92+
"\n\u2139\uFE0F You should still review the skill and/or get them from trusted sources.",
93+
);
94+
process.stdout.write(`Install skill "${skillName}"? [Y/n] `);
7895
const answer = await readLine();
79-
return answer.toLowerCase() === "y";
96+
return answer === "" || answer.toLowerCase() === "y";
8097
},
8198
});
8299

83100
if (!result.ok) {
84-
console.error(`\n✗ ${result.error.message}`);
101+
if (!(result.error instanceof InstallCancelledError)) {
102+
console.error(`\n\u2717 ${result.error.message}`);
103+
}
85104
process.exit(1);
86105
}
87106

88107
const ver = result.value.skill.version.slice(0, 7);
89-
console.log(`\n Installed skill "${result.value.name}" (${ver})`);
108+
console.log(`\n\u2713 Installed skill "${result.value.name}" (${ver})`);
90109
}
91110

92111
async function handleRemove(name: string): Promise<void> {
@@ -177,15 +196,12 @@ function printUpdateResult(r: UpdateResult): void {
177196
}
178197
}
179198

180-
function formatScanWarning(scan: ScanResult): string {
181-
const lines = ["\n⚠ This skill contains non-text files:\n"];
182-
for (const file of scan.executable) {
183-
lines.push(` executable: ${file}`);
184-
}
185-
for (const file of scan.unknown) {
186-
lines.push(` unknown: ${file}`);
199+
function formatSecurityReport(report: SecurityReport): string {
200+
const lines: string[] = [];
201+
for (const check of report.checks) {
202+
const status = check.passed ? "\u2705" : "\u274C";
203+
lines.push(`${check.emoji} ${check.label} ${status}`);
187204
}
188-
lines.push("\nThese files could execute code on your machine.");
189205
return lines.join("\n");
190206
}
191207

src/services/skill-install-service.ts

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Skill install service.
33
*
44
* Delegates fetching to a SkillSourceProvider, then runs the common pipeline:
5-
* validate → security scan → collision check → deploy → persist.
5+
* validate → security checks → collision check → deploy → persist.
66
*/
77

88
import { readdir } from "node:fs/promises";
@@ -26,13 +26,32 @@ export interface ScanResult {
2626
unknown: string[];
2727
}
2828

29+
export interface SecurityCheckEntry {
30+
emoji: string;
31+
label: string;
32+
passed: boolean;
33+
details: string[];
34+
failureMessage: string;
35+
}
36+
37+
export interface SecurityReport {
38+
checks: SecurityCheckEntry[];
39+
allPassed: boolean;
40+
}
41+
42+
/** Sentinel error signalling the install was cancelled via callback. */
43+
export class InstallCancelledError extends Error {
44+
override name = "InstallCancelledError";
45+
constructor(message = "Installation cancelled.") {
46+
super(message);
47+
}
48+
}
49+
2950
export interface InstallOptions {
30-
/** Skip security scan prompt and install regardless. */
31-
force?: boolean;
32-
/** Abort if any non-text files are found (for scripted usage). */
33-
safeOnly?: boolean;
34-
/** Callback for confirming installation when executable files are found. */
35-
confirm?: (scan: ScanResult) => Promise<boolean>;
51+
/** Skip all security checks and confirmation prompt. */
52+
yolo?: boolean;
53+
/** Called with security check results and skill name. Return true to proceed. */
54+
onSecurityCheck?: (report: SecurityReport, skillName: string) => Promise<boolean>;
3655
/** Callback to let user choose from multiple skills (marketplace repos). */
3756
selectSkill?: (skills: { name: string; description?: string }[]) => Promise<string | null>;
3857
/** Override auto-detected provider (e.g., from --github or --clawdhub flag). */
@@ -81,9 +100,15 @@ export async function installSkill(
81100
const validation = await validateSkillStructure(fetchResult.value.skillDir);
82101
if (!validation.ok) return validation;
83102

84-
// Security scan
85-
const scanResult = await enforceSecurityScan(fetchResult.value.skillDir, options);
86-
if (!scanResult.ok) return scanResult;
103+
// Security checks (unless --yolo)
104+
if (!options.yolo) {
105+
const securityResult = await enforceSecurityChecks(
106+
fetchResult.value.skillDir,
107+
validation.value.name,
108+
options,
109+
);
110+
if (!securityResult.ok) return securityResult;
111+
}
87112

88113
// Check for name collision
89114
const collisionResult = await checkNameCollision(shakaHome, validation.value.name);
@@ -127,27 +152,87 @@ export async function scanForExecutableContent(skillPath: string): Promise<ScanR
127152
return result;
128153
}
129154

130-
// --- Internal helpers ---
155+
// --- Security checks ---
131156

132-
async function enforceSecurityScan(
133-
skillPath: string,
134-
options: InstallOptions,
135-
): Promise<Result<void, Error>> {
157+
/**
158+
* Run all security checks on a skill directory.
159+
*/
160+
export async function runSecurityChecks(skillPath: string): Promise<SecurityReport> {
161+
const checks = await Promise.all([
162+
checkExecutables(skillPath),
163+
checkSuspiciousLinks(skillPath),
164+
checkHtmlComments(skillPath),
165+
]);
166+
return {
167+
checks,
168+
allPassed: checks.every((c) => c.passed),
169+
};
170+
}
171+
172+
async function checkExecutables(skillPath: string): Promise<SecurityCheckEntry> {
136173
const scan = await scanForExecutableContent(skillPath);
137-
const hasRiskyFiles = scan.executable.length > 0 || scan.unknown.length > 0;
174+
const hasRisky = scan.executable.length > 0 || scan.unknown.length > 0;
175+
return {
176+
emoji: "\u{1F3C3}",
177+
label: "No executables",
178+
passed: !hasRisky,
179+
details: [...scan.executable, ...scan.unknown],
180+
failureMessage: "Skill contains executable files, make sure to review it properly.",
181+
};
182+
}
138183

139-
if (!hasRiskyFiles) return ok(undefined);
184+
async function checkSuspiciousLinks(skillPath: string): Promise<SecurityCheckEntry> {
185+
const files = await collectFiles(skillPath);
186+
const mdFiles = files.filter((f) => f.toLowerCase().endsWith(".md"));
187+
const flagged: string[] = [];
188+
const pattern = /\b(curl|wget)\b/;
189+
for (const file of mdFiles) {
190+
const content = await Bun.file(join(skillPath, file)).text();
191+
if (pattern.test(content)) {
192+
flagged.push(file);
193+
}
194+
}
195+
return {
196+
emoji: "\u{1F517}",
197+
label: "No links",
198+
passed: flagged.length === 0,
199+
details: flagged,
200+
failureMessage: "Skill contains curl/wget commands, make sure to review it properly.",
201+
};
202+
}
140203

141-
if (options.safeOnly) {
142-
const flagged = [...scan.executable, ...scan.unknown].join(", ");
143-
return err(new Error(`Skill contains non-text files (${flagged}). Aborting (--safe-only).`));
204+
async function checkHtmlComments(skillPath: string): Promise<SecurityCheckEntry> {
205+
const files = await collectFiles(skillPath);
206+
const mdFiles = files.filter((f) => f.toLowerCase().endsWith(".md"));
207+
const flagged: string[] = [];
208+
for (const file of mdFiles) {
209+
const content = await Bun.file(join(skillPath, file)).text();
210+
if (content.includes("<!--")) {
211+
flagged.push(file);
212+
}
144213
}
214+
return {
215+
emoji: "\u{1F977}",
216+
label: "No html comments",
217+
passed: flagged.length === 0,
218+
details: flagged,
219+
failureMessage: "Skill has HTML comments in markdown, make sure to review it properly.",
220+
};
221+
}
145222

146-
if (options.force) return ok(undefined);
223+
// --- Internal helpers ---
147224

148-
const confirmed = options.confirm ? await options.confirm(scan) : false;
149-
if (!confirmed) {
150-
return err(new Error("Installation cancelled by user."));
225+
async function enforceSecurityChecks(
226+
skillPath: string,
227+
skillName: string,
228+
options: InstallOptions,
229+
): Promise<Result<void, Error>> {
230+
const report = await runSecurityChecks(skillPath);
231+
if (options.onSecurityCheck) {
232+
const proceed = await options.onSecurityCheck(report, skillName);
233+
if (!proceed) return err(new InstallCancelledError());
234+
} else if (!report.allPassed) {
235+
return err(new Error("Security checks failed."));
151236
}
152237
return ok(undefined);
153238
}

0 commit comments

Comments
 (0)