@skillguard/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release of `@skillguard/cli`
6
+ - Added `init`, `scan`, and `verify` commands
7
+ - Added CI-friendly fail thresholds and JSON output
8
+ - Added Ed25519 signature verification via SkillGuard JWKS
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @skillguard/cli
2
+
3
+ Security scanner CLI for AI agent `SKILL.md` files.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx @skillguard/cli scan SKILL.md
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ npx @skillguard/cli init
15
+ ```
16
+
17
+ You can also provide the key via env:
18
+
19
+ ```bash
20
+ export SKILLGUARD_API_KEY="sgk_..."
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Scan one file
26
+
27
+ ```bash
28
+ npx @skillguard/cli scan ./SKILL.md
29
+ ```
30
+
31
+ ### Scan a directory
32
+
33
+ ```bash
34
+ npx @skillguard/cli scan ./skills --fail-on warning
35
+ ```
36
+
37
+ ### JSON output for CI
38
+
39
+ ```bash
40
+ npx @skillguard/cli scan ./skills --json > skillguard-report.json
41
+ ```
42
+
43
+ ### Verify signature
44
+
45
+ ```bash
46
+ npx @skillguard/cli verify ./SKILL.md
47
+ ```
48
+
49
+ ## Options
50
+
51
+ ### scan
52
+
53
+ - `--json`
54
+ - `--fail-on <safe|warning|dangerous>` (default: `warning`)
55
+ - `--timeout <ms>` (default: `30000`)
56
+ - `--base-url <url>` (default: `https://skillguard.ai`)
57
+ - `--api-key <key>`
58
+ - `--dry-run`
59
+ - `--quiet`
60
+ - `--no-color`
61
+
62
+ ### verify
63
+
64
+ - `--json`
65
+ - `--timeout <ms>`
66
+ - `--base-url <url>`
67
+
68
+ ## Exit codes
69
+
70
+ - `0` success / below threshold
71
+ - `1` threshold exceeded (scan) or invalid/tampered signature (verify)
72
+ - `2` usage/input error
73
+ - `3` network/API/runtime external failure
74
+
75
+ ## GitHub Actions example
76
+
77
+ ```yaml
78
+ name: Skill Security Scan
79
+ on: [push, pull_request]
80
+
81
+ jobs:
82
+ scan:
83
+ runs-on: ubuntu-latest
84
+ steps:
85
+ - uses: actions/checkout@v4
86
+ - uses: actions/setup-node@v4
87
+ with:
88
+ node-version: '20'
89
+ - run: npx @skillguard/cli scan ./skills
90
+ env:
91
+ SKILLGUARD_API_KEY: ${{ secrets.SKILLGUARD_API_KEY }}
92
+ ```
93
+
94
+ More info: [skillguard.ai](https://skillguard.ai)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { runInit } from './commands/init.js';
4
+ import { runScan } from './commands/scan.js';
5
+ import { runVerify } from './commands/verify.js';
6
+ const VERSION = '0.1.0';
7
+ function printUsage() {
8
+ console.log(`SkillGuard CLI ${VERSION}
9
+
10
+ Usage:
11
+ skillguard init [--api-key <key>] [--base-url <url>]
12
+ skillguard scan <path> [--json] [--fail-on <safe|warning|dangerous>] [--timeout <ms>] [--base-url <url>] [--api-key <key>] [--dry-run] [--quiet] [--no-color]
13
+ skillguard verify <path> [--json] [--timeout <ms>] [--base-url <url>]
14
+
15
+ Options:
16
+ --help Show help
17
+ --version Show version
18
+ `);
19
+ }
20
+ function toPositiveInteger(raw, fallback) {
21
+ if (!raw) {
22
+ return fallback;
23
+ }
24
+ const parsed = Number.parseInt(raw, 10);
25
+ if (!Number.isInteger(parsed) || parsed <= 0) {
26
+ throw new Error('Expected a positive integer value.');
27
+ }
28
+ return parsed;
29
+ }
30
+ function toFailOn(raw) {
31
+ if (!raw) {
32
+ return 'warning';
33
+ }
34
+ if (raw === 'safe' || raw === 'warning' || raw === 'dangerous') {
35
+ return raw;
36
+ }
37
+ throw new Error("Invalid --fail-on value. Use 'safe', 'warning', or 'dangerous'.");
38
+ }
39
+ function parseShared(args) {
40
+ const parsed = parseArgs({
41
+ args,
42
+ allowPositionals: true,
43
+ options: {
44
+ help: { type: 'boolean' },
45
+ version: { type: 'boolean' },
46
+ json: { type: 'boolean' },
47
+ 'dry-run': { type: 'boolean' },
48
+ quiet: { type: 'boolean' },
49
+ 'no-color': { type: 'boolean' },
50
+ 'fail-on': { type: 'string' },
51
+ timeout: { type: 'string' },
52
+ 'base-url': { type: 'string' },
53
+ 'api-key': { type: 'string' },
54
+ },
55
+ strict: false,
56
+ });
57
+ return {
58
+ options: parsed.values,
59
+ positionals: parsed.positionals,
60
+ };
61
+ }
62
+ async function run() {
63
+ const argv = process.argv.slice(2);
64
+ const command = argv[0];
65
+ if (!command || command === '--help' || command === '-h') {
66
+ printUsage();
67
+ return 0;
68
+ }
69
+ if (command === '--version' || command === '-v') {
70
+ console.log(VERSION);
71
+ return 0;
72
+ }
73
+ const { options, positionals } = parseShared(argv.slice(1));
74
+ if (options.version) {
75
+ console.log(VERSION);
76
+ return 0;
77
+ }
78
+ if (options.help) {
79
+ printUsage();
80
+ return 0;
81
+ }
82
+ if (command === 'init') {
83
+ return await runInit({
84
+ apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
85
+ baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
86
+ });
87
+ }
88
+ if (command === 'scan') {
89
+ const pathArg = positionals[0];
90
+ if (!pathArg) {
91
+ console.error('Missing required <path> argument.');
92
+ return 2;
93
+ }
94
+ let parsedTimeout;
95
+ let failOn;
96
+ try {
97
+ parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
98
+ failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
99
+ }
100
+ catch (error) {
101
+ console.error(error.message);
102
+ return 2;
103
+ }
104
+ const scanOptions = {
105
+ json: options.json === true,
106
+ failOn,
107
+ timeoutMs: parsedTimeout,
108
+ baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
109
+ apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
110
+ dryRun: options['dry-run'] === true,
111
+ quiet: options.quiet === true,
112
+ noColor: options['no-color'] === true,
113
+ };
114
+ return await runScan(pathArg, scanOptions);
115
+ }
116
+ if (command === 'verify') {
117
+ const pathArg = positionals[0];
118
+ if (!pathArg) {
119
+ console.error('Missing required <path> argument.');
120
+ return 2;
121
+ }
122
+ let parsedTimeout;
123
+ try {
124
+ parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
125
+ }
126
+ catch (error) {
127
+ console.error(error.message);
128
+ return 2;
129
+ }
130
+ const verifyOptions = {
131
+ baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
132
+ timeoutMs: parsedTimeout,
133
+ json: options.json === true,
134
+ };
135
+ return await runVerify(pathArg, verifyOptions);
136
+ }
137
+ console.error(`Unknown command: ${command}`);
138
+ printUsage();
139
+ return 2;
140
+ }
141
+ run()
142
+ .then((code) => {
143
+ process.exitCode = code;
144
+ })
145
+ .catch(() => {
146
+ console.error('Unexpected CLI error.');
147
+ process.exitCode = 3;
148
+ });
@@ -0,0 +1,5 @@
1
+ export interface InitOptions {
2
+ apiKey?: string;
3
+ baseUrl?: string;
4
+ }
5
+ export declare function runInit(options: InitOptions): Promise<number>;
@@ -0,0 +1,31 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import { DEFAULT_BASE_URL, getConfigPath, writeConfig } from '../lib/config.js';
4
+ function sanitizeApiKey(value) {
5
+ const key = value.trim();
6
+ if (!key) {
7
+ throw new Error('API key cannot be empty.');
8
+ }
9
+ return key;
10
+ }
11
+ async function promptApiKey() {
12
+ const rl = createInterface({ input, output });
13
+ try {
14
+ const answer = await rl.question('Enter your SkillGuard API key: ');
15
+ return sanitizeApiKey(answer);
16
+ }
17
+ finally {
18
+ rl.close();
19
+ }
20
+ }
21
+ export async function runInit(options) {
22
+ const apiKey = options.apiKey ? sanitizeApiKey(options.apiKey) : await promptApiKey();
23
+ const apiUrl = (options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
24
+ const configPath = getConfigPath();
25
+ await writeConfig({
26
+ apiKey,
27
+ apiUrl,
28
+ }, configPath);
29
+ console.log(`API key saved to ${configPath}`);
30
+ return 0;
31
+ }
@@ -0,0 +1,18 @@
1
+ import { type ScanFinding, type ScanScore } from '../lib/api.js';
2
+ export interface ScanOptions {
3
+ json: boolean;
4
+ failOn: ScanScore;
5
+ timeoutMs: number;
6
+ baseUrl?: string;
7
+ apiKey?: string;
8
+ dryRun: boolean;
9
+ quiet: boolean;
10
+ noColor: boolean;
11
+ }
12
+ export interface ScanResult {
13
+ file: string;
14
+ score: ScanScore;
15
+ findings: ScanFinding[];
16
+ signatureStatus: string;
17
+ }
18
+ export declare function runScan(inputPath: string, options: ScanOptions): Promise<number>;
@@ -0,0 +1,193 @@
1
+ import { readFile, readdir, realpath } from 'node:fs/promises';
2
+ import { basename, resolve, sep } from 'node:path';
3
+ import { normalizeFindings, scanContent, toScanScore } from '../lib/api.js';
4
+ import { normalizeBaseUrl, readConfig, resolveApiKey } from '../lib/config.js';
5
+ import { renderSingleScan, renderSummary, shouldUseColor, summarizeScores } from '../lib/format.js';
6
+ const FAIL_LEVELS = {
7
+ safe: 0,
8
+ warning: 1,
9
+ dangerous: 2,
10
+ };
11
+ function shouldFail(score, failOn) {
12
+ if (failOn === 'safe') {
13
+ return score !== 'safe';
14
+ }
15
+ return FAIL_LEVELS[score] >= FAIL_LEVELS[failOn];
16
+ }
17
+ function isForbiddenFilePath(filePath) {
18
+ const name = basename(filePath).toLowerCase();
19
+ return (name === '.env' ||
20
+ name.endsWith('.pem') ||
21
+ name.endsWith('.key') ||
22
+ name.includes('id_rsa') ||
23
+ name.includes('id_ed25519'));
24
+ }
25
+ function isWithinRoot(rootPath, candidatePath) {
26
+ if (candidatePath === rootPath) {
27
+ return true;
28
+ }
29
+ return candidatePath.startsWith(`${rootPath}${sep}`);
30
+ }
31
+ async function findSkillFiles(rootPath) {
32
+ const rootRealPath = await realpath(rootPath);
33
+ const queue = [rootPath];
34
+ const found = new Set();
35
+ while (queue.length > 0) {
36
+ const current = queue.shift();
37
+ if (!current) {
38
+ continue;
39
+ }
40
+ const entries = await readdir(current, { withFileTypes: true });
41
+ for (const entry of entries) {
42
+ const fullPath = resolve(current, entry.name);
43
+ if (entry.isSymbolicLink()) {
44
+ continue;
45
+ }
46
+ if (entry.isDirectory()) {
47
+ const resolvedDir = await realpath(fullPath);
48
+ if (!isWithinRoot(rootRealPath, resolvedDir)) {
49
+ continue;
50
+ }
51
+ queue.push(fullPath);
52
+ continue;
53
+ }
54
+ if (!entry.isFile()) {
55
+ continue;
56
+ }
57
+ const entryName = entry.name.toLowerCase();
58
+ if (entryName !== 'skill.md') {
59
+ continue;
60
+ }
61
+ if (isForbiddenFilePath(fullPath)) {
62
+ continue;
63
+ }
64
+ const resolvedFile = await realpath(fullPath);
65
+ if (!isWithinRoot(rootRealPath, resolvedFile)) {
66
+ continue;
67
+ }
68
+ found.add(fullPath);
69
+ }
70
+ }
71
+ return Array.from(found).sort();
72
+ }
73
+ async function resolveTargets(inputPath) {
74
+ const path = resolve(inputPath);
75
+ const stat = await import('node:fs/promises').then((mod) => mod.lstat(path));
76
+ if (stat.isSymbolicLink()) {
77
+ throw new Error('Symlink paths are not supported.');
78
+ }
79
+ if (stat.isDirectory()) {
80
+ const files = await findSkillFiles(path);
81
+ if (files.length === 0) {
82
+ throw new Error('No SKILL.md files found in directory.');
83
+ }
84
+ return files;
85
+ }
86
+ if (!stat.isFile()) {
87
+ throw new Error('Path must be a file or directory.');
88
+ }
89
+ if (isForbiddenFilePath(path)) {
90
+ throw new Error('Refusing to scan secret-like files.');
91
+ }
92
+ return [path];
93
+ }
94
+ export async function runScan(inputPath, options) {
95
+ let baseUrl;
96
+ try {
97
+ baseUrl = normalizeBaseUrl(options.baseUrl);
98
+ }
99
+ catch (error) {
100
+ console.error(error.message);
101
+ return 2;
102
+ }
103
+ let targets;
104
+ try {
105
+ targets = await resolveTargets(inputPath);
106
+ }
107
+ catch (error) {
108
+ console.error(error.message);
109
+ return 2;
110
+ }
111
+ if (options.dryRun) {
112
+ if (!options.quiet) {
113
+ console.log(`Dry run: ${targets.length} file(s) would be scanned.`);
114
+ for (const file of targets) {
115
+ console.log(`- ${file}`);
116
+ }
117
+ }
118
+ return 0;
119
+ }
120
+ const hasApiKeyFlag = typeof options.apiKey === 'string' && options.apiKey.trim().length > 0;
121
+ const hasApiKeyEnv = typeof process.env.SKILLGUARD_API_KEY === 'string' && process.env.SKILLGUARD_API_KEY.trim().length > 0;
122
+ let config = null;
123
+ if (!hasApiKeyFlag && !hasApiKeyEnv) {
124
+ try {
125
+ config = await readConfig();
126
+ }
127
+ catch (error) {
128
+ console.error(error.message);
129
+ return 2;
130
+ }
131
+ }
132
+ const resolvedApiKey = resolveApiKey({
133
+ apiKeyFlag: options.apiKey,
134
+ env: process.env,
135
+ config,
136
+ });
137
+ if (!resolvedApiKey) {
138
+ console.error("No API key found. Run 'skillguard init' or set SKILLGUARD_API_KEY.");
139
+ return 2;
140
+ }
141
+ const color = shouldUseColor(options.noColor);
142
+ const results = [];
143
+ for (const filePath of targets) {
144
+ const content = await readFile(filePath, 'utf8');
145
+ let apiResponse;
146
+ try {
147
+ apiResponse = await scanContent({
148
+ baseUrl,
149
+ apiKey: resolvedApiKey.apiKey,
150
+ content,
151
+ timeoutMs: options.timeoutMs,
152
+ });
153
+ }
154
+ catch (error) {
155
+ console.error(error.message);
156
+ return 3;
157
+ }
158
+ const findings = normalizeFindings(apiResponse.findings);
159
+ const score = toScanScore(apiResponse.score || apiResponse.overallRisk);
160
+ const signatureStatus = typeof apiResponse.signatureStatus === 'string'
161
+ ? apiResponse.signatureStatus
162
+ : (apiResponse.security ? 'issued' : 'disabled');
163
+ results.push({
164
+ file: filePath,
165
+ score,
166
+ findings,
167
+ signatureStatus,
168
+ });
169
+ }
170
+ const failedFiles = results.filter((result) => shouldFail(result.score, options.failOn)).map((result) => result.file);
171
+ if (!options.quiet) {
172
+ if (options.json) {
173
+ const summary = summarizeScores(results);
174
+ console.log(JSON.stringify({
175
+ cli_version: '0.1.0',
176
+ scanned_at: new Date().toISOString(),
177
+ fail_on: options.failOn,
178
+ summary,
179
+ failed_files: failedFiles,
180
+ results,
181
+ }, null, 2));
182
+ }
183
+ else {
184
+ for (const result of results) {
185
+ console.log(renderSingleScan(result, color));
186
+ }
187
+ const summary = summarizeScores(results);
188
+ const rootLabel = targets.length === 1 ? targets[0] : resolve(inputPath);
189
+ console.log(renderSummary(summary, rootLabel, color, failedFiles));
190
+ }
191
+ }
192
+ return failedFiles.length > 0 ? 1 : 0;
193
+ }
@@ -0,0 +1,6 @@
1
+ export interface VerifyOptions {
2
+ baseUrl?: string;
3
+ timeoutMs: number;
4
+ json: boolean;
5
+ }
6
+ export declare function runVerify(inputPath: string, options: VerifyOptions): Promise<number>;
@@ -0,0 +1,85 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { fetchJwks } from '../lib/api.js';
4
+ import { normalizeBaseUrl } from '../lib/config.js';
5
+ import { parseSkillContent, verifySignature } from '../lib/verify.js';
6
+ function renderHuman(result) {
7
+ if (!result.security) {
8
+ return 'Invalid signature: missing security block.';
9
+ }
10
+ const signedBy = `${String(result.security.verified_by || 'unknown')} (${String(result.security.kid || 'unknown')})`;
11
+ const score = String(result.security.score || 'unknown');
12
+ const scanned = String(result.security.scanned_at || 'unknown');
13
+ const validUntil = String(result.security.valid_until || 'unknown');
14
+ return [
15
+ result.signatureValid && result.hashMatches && !result.expired ? 'Signature valid' : 'Signature invalid',
16
+ `Signed by: ${signedBy}`,
17
+ `Score: ${score}`,
18
+ `Scanned: ${scanned}`,
19
+ `Valid until: ${validUntil}${result.expired ? ' (expired)' : ''}`,
20
+ `Content hash matches: ${result.hashMatches ? 'yes' : 'no'}`,
21
+ !result.hashMatches ? `Computed hash: ${result.computedHash}` : '',
22
+ ].filter(Boolean).join('\n');
23
+ }
24
+ export async function runVerify(inputPath, options) {
25
+ const filePath = resolve(inputPath);
26
+ let baseUrl;
27
+ try {
28
+ baseUrl = normalizeBaseUrl(options.baseUrl);
29
+ }
30
+ catch (error) {
31
+ console.error(error.message);
32
+ return 2;
33
+ }
34
+ let rawContent;
35
+ try {
36
+ rawContent = await readFile(filePath, 'utf8');
37
+ }
38
+ catch {
39
+ console.error('Could not read file.');
40
+ return 2;
41
+ }
42
+ const parsedSkill = parseSkillContent(rawContent);
43
+ if (!parsedSkill.security || typeof parsedSkill.security.signature !== 'string') {
44
+ const message = 'Invalid signature: missing security block.';
45
+ if (options.json) {
46
+ console.log(JSON.stringify({ valid: false, reason: 'missing_security_block' }, null, 2));
47
+ }
48
+ else {
49
+ console.error(message);
50
+ }
51
+ return 1;
52
+ }
53
+ let jwks;
54
+ try {
55
+ jwks = await fetchJwks({
56
+ baseUrl,
57
+ timeoutMs: options.timeoutMs,
58
+ });
59
+ }
60
+ catch (error) {
61
+ console.error(error.message);
62
+ return 3;
63
+ }
64
+ const verification = verifySignature({ parsedSkill, jwks });
65
+ const valid = verification.signatureValid && verification.hashMatches && !verification.expired;
66
+ if (options.json) {
67
+ console.log(JSON.stringify({
68
+ valid,
69
+ signatureValid: verification.signatureValid,
70
+ hashMatches: verification.hashMatches,
71
+ expired: verification.expired,
72
+ security: verification.security,
73
+ }, null, 2));
74
+ }
75
+ else {
76
+ const output = renderHuman(verification);
77
+ if (valid) {
78
+ console.log(output);
79
+ }
80
+ else {
81
+ console.error(output);
82
+ }
83
+ }
84
+ return valid ? 0 : 1;
85
+ }
@@ -0,0 +1,36 @@
1
+ export type ScanScore = 'safe' | 'warning' | 'dangerous';
2
+ export interface ScanFinding {
3
+ severity?: string;
4
+ title?: string;
5
+ description?: string;
6
+ recommendation?: string;
7
+ }
8
+ export interface ScanApiResponse {
9
+ score?: string;
10
+ overallRisk?: string;
11
+ findings?: unknown;
12
+ signatureStatus?: string;
13
+ security?: Record<string, unknown>;
14
+ signedSkillContent?: string;
15
+ [key: string]: unknown;
16
+ }
17
+ export interface SanitizedApiError {
18
+ message: string;
19
+ exitCode: 2 | 3;
20
+ }
21
+ export declare function toScanScore(value: unknown): ScanScore;
22
+ export declare function scanContent(input: {
23
+ baseUrl: string;
24
+ apiKey: string;
25
+ content: string;
26
+ timeoutMs: number;
27
+ fetchImpl?: typeof fetch;
28
+ }): Promise<ScanApiResponse>;
29
+ export declare function fetchJwks(input: {
30
+ baseUrl: string;
31
+ timeoutMs: number;
32
+ fetchImpl?: typeof fetch;
33
+ }): Promise<{
34
+ keys: Array<Record<string, unknown>>;
35
+ }>;
36
+ export declare function normalizeFindings(rawFindings: unknown): ScanFinding[];
@@ -0,0 +1,155 @@
1
+ export function toScanScore(value) {
2
+ if (value === 'safe' || value === 'warning' || value === 'dangerous') {
3
+ return value;
4
+ }
5
+ return 'safe';
6
+ }
7
+ function withTimeout(ms) {
8
+ const controller = new AbortController();
9
+ const timeout = setTimeout(() => controller.abort(), ms);
10
+ timeout.unref?.();
11
+ return {
12
+ signal: controller.signal,
13
+ cancel: () => clearTimeout(timeout),
14
+ };
15
+ }
16
+ function shouldRetryStatus(status) {
17
+ return status === 408 || status === 429 || status >= 500;
18
+ }
19
+ async function fetchWithRetry(fetchFn, input, init, retries) {
20
+ let attempt = 0;
21
+ let lastError = null;
22
+ while (attempt <= retries) {
23
+ try {
24
+ const response = await fetchFn(input, init);
25
+ if (attempt < retries && shouldRetryStatus(response.status)) {
26
+ attempt += 1;
27
+ continue;
28
+ }
29
+ return response;
30
+ }
31
+ catch (error) {
32
+ lastError = error;
33
+ if (attempt >= retries) {
34
+ throw error;
35
+ }
36
+ attempt += 1;
37
+ }
38
+ }
39
+ throw lastError || new Error('Network request failed.');
40
+ }
41
+ async function parseJsonSafe(response) {
42
+ try {
43
+ return await response.json();
44
+ }
45
+ catch {
46
+ throw new Error('Unexpected API response.');
47
+ }
48
+ }
49
+ function mapStatusMessage(status) {
50
+ if (status === 401) {
51
+ return "Invalid API key. Run 'skillguard init' or set SKILLGUARD_API_KEY.";
52
+ }
53
+ if (status === 429) {
54
+ return 'Rate limit exceeded. Wait and retry.';
55
+ }
56
+ if (status >= 500) {
57
+ return 'SkillGuard API error. Try again later.';
58
+ }
59
+ return 'SkillGuard API request failed.';
60
+ }
61
+ export async function scanContent(input) {
62
+ const fetchFn = input.fetchImpl || fetch;
63
+ const endpoint = `${input.baseUrl}/api/v1/scan`;
64
+ const timeout = withTimeout(input.timeoutMs);
65
+ try {
66
+ const response = await fetchWithRetry(fetchFn, endpoint, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'X-API-Key': input.apiKey,
70
+ 'Content-Type': 'application/json',
71
+ },
72
+ body: JSON.stringify({ content: input.content }),
73
+ signal: timeout.signal,
74
+ }, 1);
75
+ if (!response.ok) {
76
+ throw new Error(mapStatusMessage(response.status));
77
+ }
78
+ const parsed = await parseJsonSafe(response);
79
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
80
+ throw new Error('Unexpected API response.');
81
+ }
82
+ return parsed;
83
+ }
84
+ catch (error) {
85
+ const typed = error;
86
+ if (typed.name === 'AbortError') {
87
+ throw new Error('Cannot reach skillguard.ai. Check your connection.');
88
+ }
89
+ if (typed.message === 'Unexpected API response.') {
90
+ throw typed;
91
+ }
92
+ if (typed.message.includes('Invalid API key.') ||
93
+ typed.message.includes('Rate limit exceeded.') ||
94
+ typed.message.includes('SkillGuard API error.') ||
95
+ typed.message.includes('SkillGuard API request failed.')) {
96
+ throw typed;
97
+ }
98
+ throw new Error('Cannot reach skillguard.ai. Check your connection.');
99
+ }
100
+ finally {
101
+ timeout.cancel();
102
+ }
103
+ }
104
+ export async function fetchJwks(input) {
105
+ const fetchFn = input.fetchImpl || fetch;
106
+ const endpoint = `${input.baseUrl}/.well-known/skillguard-jwks.json`;
107
+ const timeout = withTimeout(input.timeoutMs);
108
+ try {
109
+ const response = await fetchWithRetry(fetchFn, endpoint, {
110
+ method: 'GET',
111
+ signal: timeout.signal,
112
+ }, 1);
113
+ if (!response.ok) {
114
+ throw new Error('Could not fetch SkillGuard JWKS.');
115
+ }
116
+ const parsed = await parseJsonSafe(response);
117
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
118
+ throw new Error('Could not fetch SkillGuard JWKS.');
119
+ }
120
+ const keys = parsed.keys;
121
+ if (!Array.isArray(keys)) {
122
+ throw new Error('Could not fetch SkillGuard JWKS.');
123
+ }
124
+ return { keys: keys };
125
+ }
126
+ catch (error) {
127
+ const typed = error;
128
+ if (typed.name === 'AbortError') {
129
+ throw new Error('Could not fetch SkillGuard JWKS.');
130
+ }
131
+ if (typed.message === 'Could not fetch SkillGuard JWKS.') {
132
+ throw typed;
133
+ }
134
+ throw new Error('Could not fetch SkillGuard JWKS.');
135
+ }
136
+ finally {
137
+ timeout.cancel();
138
+ }
139
+ }
140
+ export function normalizeFindings(rawFindings) {
141
+ if (!Array.isArray(rawFindings)) {
142
+ return [];
143
+ }
144
+ return rawFindings
145
+ .filter((item) => item && typeof item === 'object' && !Array.isArray(item))
146
+ .map((item) => {
147
+ const finding = item;
148
+ return {
149
+ severity: typeof finding.severity === 'string' ? finding.severity : undefined,
150
+ title: typeof finding.title === 'string' ? finding.title : undefined,
151
+ description: typeof finding.description === 'string' ? finding.description : undefined,
152
+ recommendation: typeof finding.recommendation === 'string' ? finding.recommendation : undefined,
153
+ };
154
+ });
155
+ }
@@ -0,0 +1,19 @@
1
+ export declare const DEFAULT_BASE_URL = "https://skillguard.ai";
2
+ export interface SkillguardCliConfig {
3
+ apiKey?: string;
4
+ apiUrl?: string;
5
+ }
6
+ export interface ResolvedApiKey {
7
+ apiKey: string;
8
+ source: 'flag' | 'env' | 'config';
9
+ }
10
+ export interface ApiKeyResolutionInput {
11
+ apiKeyFlag?: string;
12
+ config?: SkillguardCliConfig | null;
13
+ env?: NodeJS.ProcessEnv;
14
+ }
15
+ export declare function getConfigPath(home?: string): string;
16
+ export declare function readConfig(configPath?: string): Promise<SkillguardCliConfig | null>;
17
+ export declare function writeConfig(config: SkillguardCliConfig, configPath?: string): Promise<void>;
18
+ export declare function normalizeBaseUrl(value?: string): string;
19
+ export declare function resolveApiKey(input: ApiKeyResolutionInput): ResolvedApiKey | null;
@@ -0,0 +1,73 @@
1
+ import { mkdir, readFile, chmod, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const DEFAULT_BASE_URL = 'https://skillguard.ai';
5
+ export function getConfigPath(home = homedir()) {
6
+ return join(home, '.config', 'skillguard', 'config.json');
7
+ }
8
+ function isNonEmptyString(value) {
9
+ return typeof value === 'string' && value.trim().length > 0;
10
+ }
11
+ export async function readConfig(configPath = getConfigPath()) {
12
+ try {
13
+ const raw = await readFile(configPath, 'utf8');
14
+ const parsed = JSON.parse(raw);
15
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
16
+ return null;
17
+ }
18
+ return {
19
+ apiKey: isNonEmptyString(parsed.apiKey) ? parsed.apiKey.trim() : undefined,
20
+ apiUrl: isNonEmptyString(parsed.apiUrl) ? parsed.apiUrl.trim() : undefined,
21
+ };
22
+ }
23
+ catch (error) {
24
+ const typed = error;
25
+ if (typed?.code === 'ENOENT') {
26
+ return null;
27
+ }
28
+ throw new Error('Failed to read CLI config.');
29
+ }
30
+ }
31
+ export async function writeConfig(config, configPath = getConfigPath()) {
32
+ const dirPath = dirname(configPath);
33
+ await mkdir(dirPath, { recursive: true, mode: 0o700 });
34
+ await chmod(dirPath, 0o700);
35
+ const body = JSON.stringify({
36
+ apiKey: config.apiKey,
37
+ apiUrl: config.apiUrl || DEFAULT_BASE_URL,
38
+ }, null, 2);
39
+ await writeFile(configPath, `${body}\n`, { encoding: 'utf8', mode: 0o600 });
40
+ await chmod(configPath, 0o600);
41
+ }
42
+ export function normalizeBaseUrl(value) {
43
+ const raw = (value || DEFAULT_BASE_URL).trim();
44
+ const withoutTrailingSlash = raw.replace(/\/+$/, '');
45
+ if (!withoutTrailingSlash) {
46
+ return DEFAULT_BASE_URL;
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = new URL(withoutTrailingSlash);
51
+ }
52
+ catch {
53
+ throw new Error('Invalid --base-url value.');
54
+ }
55
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
56
+ throw new Error('Invalid --base-url protocol. Use http or https.');
57
+ }
58
+ return parsed.toString().replace(/\/+$/, '');
59
+ }
60
+ export function resolveApiKey(input) {
61
+ if (isNonEmptyString(input.apiKeyFlag)) {
62
+ return { apiKey: input.apiKeyFlag.trim(), source: 'flag' };
63
+ }
64
+ const envValue = input.env?.SKILLGUARD_API_KEY;
65
+ if (isNonEmptyString(envValue)) {
66
+ return { apiKey: envValue.trim(), source: 'env' };
67
+ }
68
+ const configValue = input.config?.apiKey;
69
+ if (isNonEmptyString(configValue)) {
70
+ return { apiKey: configValue.trim(), source: 'config' };
71
+ }
72
+ return null;
73
+ }
@@ -0,0 +1,18 @@
1
+ import { ScanFinding, ScanScore } from './api.js';
2
+ export interface ScanRenderedResult {
3
+ file: string;
4
+ score: ScanScore;
5
+ findings: ScanFinding[];
6
+ signatureStatus: string;
7
+ }
8
+ export interface ScanSummary {
9
+ total: number;
10
+ safe: number;
11
+ warning: number;
12
+ dangerous: number;
13
+ }
14
+ export declare function shouldUseColor(noColorFlag: boolean): boolean;
15
+ export declare function scoreLabel(score: ScanScore, color: boolean): string;
16
+ export declare function renderSingleScan(result: ScanRenderedResult, color: boolean): string;
17
+ export declare function summarizeScores(results: ScanRenderedResult[]): ScanSummary;
18
+ export declare function renderSummary(summary: ScanSummary, rootPath: string, color: boolean, failedFiles: string[]): string;
@@ -0,0 +1,92 @@
1
+ const RESET = '\u001B[0m';
2
+ const GREEN = '\u001B[32m';
3
+ const YELLOW = '\u001B[33m';
4
+ const RED = '\u001B[31m';
5
+ const CYAN = '\u001B[36m';
6
+ const DIM = '\u001B[2m';
7
+ function maybeColor(enabled, color, text) {
8
+ return enabled ? `${color}${text}${RESET}` : text;
9
+ }
10
+ export function shouldUseColor(noColorFlag) {
11
+ if (noColorFlag) {
12
+ return false;
13
+ }
14
+ if (process.env.NO_COLOR && process.env.NO_COLOR.length > 0) {
15
+ return false;
16
+ }
17
+ return process.stdout.isTTY !== false;
18
+ }
19
+ export function scoreLabel(score, color) {
20
+ if (score === 'safe') {
21
+ return maybeColor(color, GREEN, 'safe');
22
+ }
23
+ if (score === 'warning') {
24
+ return maybeColor(color, YELLOW, 'warning');
25
+ }
26
+ return maybeColor(color, RED, 'dangerous');
27
+ }
28
+ function statusLabel(status, color) {
29
+ return status === 'issued'
30
+ ? maybeColor(color, CYAN, 'issued')
31
+ : maybeColor(color, DIM, 'disabled');
32
+ }
33
+ export function renderSingleScan(result, color) {
34
+ const lines = [
35
+ '┌─────────────────────────────────────────┐',
36
+ '│ SkillGuard Scan Report │',
37
+ '├─────────────────────────────────────────┤',
38
+ `│ File: ${truncate(result.file, 30).padEnd(30, ' ')} │`,
39
+ `│ Score: ${padInline(scoreLabel(result.score, color), 28)} │`,
40
+ `│ Findings: ${String(result.findings.length).padEnd(28, ' ')} │`,
41
+ `│ Signature:${padInline(statusLabel(result.signatureStatus, color), 28)} │`,
42
+ ];
43
+ if (result.findings.length > 0) {
44
+ lines.push('├─────────────────────────────────────────┤');
45
+ for (const finding of result.findings.slice(0, 3)) {
46
+ const sev = (finding.severity || 'info').toUpperCase();
47
+ const title = finding.title || finding.description || 'Finding';
48
+ lines.push(`│ [${sev}] ${truncate(title, 30).padEnd(30, ' ')} │`);
49
+ }
50
+ }
51
+ lines.push('└─────────────────────────────────────────┘');
52
+ return lines.join('\n');
53
+ }
54
+ function truncate(value, limit) {
55
+ if (value.length <= limit) {
56
+ return value;
57
+ }
58
+ return `${value.slice(0, limit - 1)}…`;
59
+ }
60
+ function padInline(value, target) {
61
+ const strippedLength = value.replace(/\u001B\[[0-9;]*m/g, '').length;
62
+ if (strippedLength >= target) {
63
+ return value;
64
+ }
65
+ return value + ' '.repeat(target - strippedLength);
66
+ }
67
+ export function summarizeScores(results) {
68
+ const summary = {
69
+ total: results.length,
70
+ safe: 0,
71
+ warning: 0,
72
+ dangerous: 0,
73
+ };
74
+ for (const result of results) {
75
+ summary[result.score] += 1;
76
+ }
77
+ return summary;
78
+ }
79
+ export function renderSummary(summary, rootPath, color, failedFiles) {
80
+ const lines = [
81
+ `Scanned ${summary.total} skills in ${rootPath}`,
82
+ '',
83
+ ` ${maybeColor(color, GREEN, 'safe')}: ${String(summary.safe).padStart(4, ' ')}`,
84
+ ` ${maybeColor(color, YELLOW, 'warning')}: ${String(summary.warning).padStart(4, ' ')}`,
85
+ ` ${maybeColor(color, RED, 'dangerous')}: ${String(summary.dangerous).padStart(4, ' ')}`,
86
+ ];
87
+ if (failedFiles.length > 0) {
88
+ lines.push('');
89
+ lines.push(`Failed: ${failedFiles.join(', ')}`);
90
+ }
91
+ return lines.join('\n');
92
+ }
@@ -0,0 +1,21 @@
1
+ export interface ParsedSkillFile {
2
+ normalizedContent: string;
3
+ body: string;
4
+ unsignedContent: string;
5
+ security: Record<string, unknown> | null;
6
+ }
7
+ export interface VerificationResult {
8
+ signatureValid: boolean;
9
+ hashMatches: boolean;
10
+ expired: boolean;
11
+ security: Record<string, unknown> | null;
12
+ computedHash: string;
13
+ }
14
+ export declare function parseSkillContent(content: string): ParsedSkillFile;
15
+ export declare function buildSignaturePayload(security: Record<string, unknown>): Record<string, unknown>;
16
+ export declare function verifySignature(input: {
17
+ parsedSkill: ParsedSkillFile;
18
+ jwks: {
19
+ keys: Array<Record<string, unknown>>;
20
+ };
21
+ }): VerificationResult;
@@ -0,0 +1,193 @@
1
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
2
+ function normalizeLineEndings(value) {
3
+ return value.replace(/\r\n?/g, '\n');
4
+ }
5
+ function parseScalar(raw) {
6
+ const value = raw.trim();
7
+ if (value === 'null' || value === '~') {
8
+ return null;
9
+ }
10
+ if (value === 'true') {
11
+ return true;
12
+ }
13
+ if (value === 'false') {
14
+ return false;
15
+ }
16
+ if (/^-?\d+$/.test(value)) {
17
+ return Number.parseInt(value, 10);
18
+ }
19
+ if (/^-?\d+\.\d+$/.test(value)) {
20
+ return Number.parseFloat(value);
21
+ }
22
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
23
+ return value.slice(1, -1);
24
+ }
25
+ return value;
26
+ }
27
+ function extractFrontMatter(content) {
28
+ const normalized = normalizeLineEndings(content);
29
+ if (!normalized.startsWith('---\n')) {
30
+ return {
31
+ frontMatterLines: [],
32
+ body: normalized,
33
+ };
34
+ }
35
+ const closeIdx = normalized.indexOf('\n---\n', 4);
36
+ if (closeIdx === -1) {
37
+ return {
38
+ frontMatterLines: [],
39
+ body: normalized,
40
+ };
41
+ }
42
+ const frontMatterRaw = normalized.slice(4, closeIdx);
43
+ const body = normalized.slice(closeIdx + 5);
44
+ return {
45
+ frontMatterLines: frontMatterRaw.length > 0 ? frontMatterRaw.split('\n') : [],
46
+ body,
47
+ };
48
+ }
49
+ function splitSecurityBlock(frontMatterLines) {
50
+ const withoutSecurity = [];
51
+ const security = {};
52
+ let index = 0;
53
+ let foundSecurity = false;
54
+ while (index < frontMatterLines.length) {
55
+ const line = frontMatterLines[index];
56
+ if (!foundSecurity && /^security:\s*$/.test(line)) {
57
+ foundSecurity = true;
58
+ index += 1;
59
+ while (index < frontMatterLines.length) {
60
+ const nested = frontMatterLines[index];
61
+ if (!/^\s+/.test(nested)) {
62
+ break;
63
+ }
64
+ const match = nested.match(/^\s{2}([A-Za-z0-9_]+):\s*(.*)$/);
65
+ if (match) {
66
+ security[match[1]] = parseScalar(match[2]);
67
+ }
68
+ index += 1;
69
+ }
70
+ continue;
71
+ }
72
+ withoutSecurity.push(line);
73
+ index += 1;
74
+ }
75
+ if (!foundSecurity) {
76
+ return {
77
+ security: null,
78
+ withoutSecurity: frontMatterLines,
79
+ };
80
+ }
81
+ return {
82
+ security,
83
+ withoutSecurity,
84
+ };
85
+ }
86
+ function buildUnsignedContent(frontMatterLines, body) {
87
+ if (frontMatterLines.length === 0) {
88
+ return body;
89
+ }
90
+ return `---\n${frontMatterLines.join('\n')}\n---\n${body}`;
91
+ }
92
+ function fromBase64Url(value) {
93
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
94
+ const padding = normalized.length % 4;
95
+ const padded = padding ? `${normalized}${'='.repeat(4 - padding)}` : normalized;
96
+ return Buffer.from(padded, 'base64');
97
+ }
98
+ function canonicalJSONStringify(value) {
99
+ if (value === null || typeof value !== 'object') {
100
+ return JSON.stringify(value);
101
+ }
102
+ if (Array.isArray(value)) {
103
+ return `[${value.map((item) => canonicalJSONStringify(item)).join(',')}]`;
104
+ }
105
+ const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
106
+ const serialized = entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJSONStringify(item)}`);
107
+ return `{${serialized.join(',')}}`;
108
+ }
109
+ function sha256Hex(value) {
110
+ return createHash('sha256').update(value, 'utf8').digest('hex');
111
+ }
112
+ export function parseSkillContent(content) {
113
+ const normalizedContent = normalizeLineEndings(content);
114
+ const parsed = extractFrontMatter(normalizedContent);
115
+ const split = splitSecurityBlock(parsed.frontMatterLines);
116
+ return {
117
+ normalizedContent,
118
+ body: parsed.body,
119
+ unsignedContent: buildUnsignedContent(split.withoutSecurity, parsed.body),
120
+ security: split.security,
121
+ };
122
+ }
123
+ export function buildSignaturePayload(security) {
124
+ return {
125
+ verified_by: security.verified_by,
126
+ version: security.version,
127
+ issuer: security.issuer,
128
+ kid: security.kid,
129
+ alg: security.alg,
130
+ scan_id: security.scan_id,
131
+ score: security.score,
132
+ scanned_at: security.scanned_at,
133
+ valid_until: security.valid_until,
134
+ content_sha256: security.content_sha256,
135
+ };
136
+ }
137
+ function findJwkByKid(jwks, kid) {
138
+ if (typeof kid !== 'string' || kid.trim().length === 0) {
139
+ return null;
140
+ }
141
+ for (const key of jwks.keys) {
142
+ if (typeof key.kid === 'string' && key.kid === kid) {
143
+ return key;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+ export function verifySignature(input) {
149
+ const security = input.parsedSkill.security;
150
+ const computedHash = sha256Hex(input.parsedSkill.unsignedContent);
151
+ if (!security || typeof security.signature !== 'string') {
152
+ return {
153
+ signatureValid: false,
154
+ hashMatches: false,
155
+ expired: false,
156
+ security,
157
+ computedHash,
158
+ };
159
+ }
160
+ const jwk = findJwkByKid(input.jwks, security.kid);
161
+ if (!jwk) {
162
+ return {
163
+ signatureValid: false,
164
+ hashMatches: false,
165
+ expired: false,
166
+ security,
167
+ computedHash,
168
+ };
169
+ }
170
+ let signatureValid = false;
171
+ try {
172
+ const publicKey = createPublicKey({
173
+ key: jwk,
174
+ format: 'jwk',
175
+ });
176
+ const payload = buildSignaturePayload(security);
177
+ signatureValid = cryptoVerify(null, Buffer.from(canonicalJSONStringify(payload), 'utf8'), publicKey, fromBase64Url(String(security.signature)));
178
+ }
179
+ catch {
180
+ signatureValid = false;
181
+ }
182
+ const expectedHash = typeof security.content_sha256 === 'string' ? security.content_sha256 : '';
183
+ const hashMatches = expectedHash.length > 0 && expectedHash === computedHash;
184
+ const validUntil = typeof security.valid_until === 'string' ? Date.parse(security.valid_until) : NaN;
185
+ const expired = Number.isFinite(validUntil) ? Date.now() > validUntil : true;
186
+ return {
187
+ signatureValid,
188
+ hashMatches,
189
+ expired,
190
+ security,
191
+ computedHash,
192
+ };
193
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@skillguard/cli",
3
+ "version": "0.1.0",
4
+ "description": "Security scanner for AI agent skill files",
5
+ "type": "module",
6
+ "bin": {
7
+ "skillguard": "./dist/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "dist/",
14
+ "README.md",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "test": "npm run build && node --test test/*.node.mjs"
20
+ },
21
+ "keywords": [
22
+ "security",
23
+ "ai",
24
+ "agent",
25
+ "skill",
26
+ "scanner",
27
+ "openclaw"
28
+ ],
29
+ "author": "",
30
+ "license": "ISC",
31
+ "devDependencies": {
32
+ "@types/node": "^24.3.0",
33
+ "typescript": "^5.7.3"
34
+ }
35
+ }