@skillguard/cli 0.1.1 → 0.4.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 +25 -0
- package/README.md +106 -10
- package/dist/cli.js +119 -25
- package/dist/commands/gate.d.ts +14 -0
- package/dist/commands/gate.js +17 -0
- package/dist/commands/limits.d.ts +8 -0
- package/dist/commands/limits.js +98 -0
- package/dist/commands/scan.d.ts +5 -2
- package/dist/commands/scan.js +72 -22
- package/dist/commands/verify.js +3 -3
- package/dist/commands/workflow.d.ts +21 -0
- package/dist/commands/workflow.js +143 -0
- package/dist/lib/api.d.ts +40 -0
- package/dist/lib/api.js +127 -0
- package/dist/lib/format.js +2 -0
- package/dist/lib/help.d.ts +2 -0
- package/dist/lib/help.js +240 -0
- package/dist/lib/version.d.ts +1 -0
- package/dist/lib/version.js +1 -0
- package/package.json +1 -1
package/dist/commands/scan.js
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { readFile, readdir, realpath } from 'node:fs/promises';
|
|
2
2
|
import { basename, resolve, sep } from 'node:path';
|
|
3
|
-
import { normalizeFindings, scanContent, toScanScore } from '../lib/api.js';
|
|
3
|
+
import { normalizeFindings, scanContent, scanContentAnonymous, toScanScore } from '../lib/api.js';
|
|
4
4
|
import { normalizeBaseUrl, readConfig, resolveApiKey } from '../lib/config.js';
|
|
5
5
|
import { renderSingleScan, renderSummary, shouldUseColor, summarizeScores } from '../lib/format.js';
|
|
6
|
+
import { CLI_VERSION } from '../lib/version.js';
|
|
6
7
|
const FAIL_LEVELS = {
|
|
7
8
|
safe: 0,
|
|
8
9
|
warning: 1,
|
|
9
10
|
dangerous: 2,
|
|
10
11
|
};
|
|
12
|
+
const WORST_EXIT_CODES = {
|
|
13
|
+
safe: 0,
|
|
14
|
+
warning: 1,
|
|
15
|
+
dangerous: 2,
|
|
16
|
+
};
|
|
11
17
|
function shouldFail(score, failOn) {
|
|
12
18
|
if (failOn === 'safe') {
|
|
13
19
|
return score !== 'safe';
|
|
14
20
|
}
|
|
15
21
|
return FAIL_LEVELS[score] >= FAIL_LEVELS[failOn];
|
|
16
22
|
}
|
|
23
|
+
function resolveWorstScore(results) {
|
|
24
|
+
if (results.some((result) => result.score === 'dangerous')) {
|
|
25
|
+
return 'dangerous';
|
|
26
|
+
}
|
|
27
|
+
if (results.some((result) => result.score === 'warning')) {
|
|
28
|
+
return 'warning';
|
|
29
|
+
}
|
|
30
|
+
return 'safe';
|
|
31
|
+
}
|
|
17
32
|
function isForbiddenFilePath(filePath) {
|
|
18
33
|
const name = basename(filePath).toLowerCase();
|
|
19
34
|
return (name === '.env' ||
|
|
@@ -28,7 +43,16 @@ function isWithinRoot(rootPath, candidatePath) {
|
|
|
28
43
|
}
|
|
29
44
|
return candidatePath.startsWith(`${rootPath}${sep}`);
|
|
30
45
|
}
|
|
31
|
-
|
|
46
|
+
function shouldSkipDirectory(entryName, options) {
|
|
47
|
+
if (options.scanAll) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (options.skipNodeModules && entryName.toLowerCase() === 'node_modules') {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
async function findSkillFiles(rootPath, options) {
|
|
32
56
|
const rootRealPath = await realpath(rootPath);
|
|
33
57
|
const queue = [rootPath];
|
|
34
58
|
const found = new Set();
|
|
@@ -44,6 +68,9 @@ async function findSkillFiles(rootPath) {
|
|
|
44
68
|
continue;
|
|
45
69
|
}
|
|
46
70
|
if (entry.isDirectory()) {
|
|
71
|
+
if (shouldSkipDirectory(entry.name, options)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
47
74
|
const resolvedDir = await realpath(fullPath);
|
|
48
75
|
if (!isWithinRoot(rootRealPath, resolvedDir)) {
|
|
49
76
|
continue;
|
|
@@ -70,14 +97,14 @@ async function findSkillFiles(rootPath) {
|
|
|
70
97
|
}
|
|
71
98
|
return Array.from(found).sort();
|
|
72
99
|
}
|
|
73
|
-
async function resolveTargets(inputPath) {
|
|
100
|
+
async function resolveTargets(inputPath, traversalOptions) {
|
|
74
101
|
const path = resolve(inputPath);
|
|
75
102
|
const stat = await import('node:fs/promises').then((mod) => mod.lstat(path));
|
|
76
103
|
if (stat.isSymbolicLink()) {
|
|
77
104
|
throw new Error('Symlink paths are not supported.');
|
|
78
105
|
}
|
|
79
106
|
if (stat.isDirectory()) {
|
|
80
|
-
const files = await findSkillFiles(path);
|
|
107
|
+
const files = await findSkillFiles(path, traversalOptions);
|
|
81
108
|
if (files.length === 0) {
|
|
82
109
|
throw new Error('No SKILL.md files found in directory.');
|
|
83
110
|
}
|
|
@@ -92,21 +119,25 @@ async function resolveTargets(inputPath) {
|
|
|
92
119
|
return [path];
|
|
93
120
|
}
|
|
94
121
|
export async function runScan(inputPath, options) {
|
|
122
|
+
const scanRoot = inputPath ? resolve(inputPath) : process.cwd();
|
|
95
123
|
let baseUrl;
|
|
96
124
|
try {
|
|
97
125
|
baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
98
126
|
}
|
|
99
127
|
catch (error) {
|
|
100
128
|
console.error(error.message);
|
|
101
|
-
return
|
|
129
|
+
return 4;
|
|
102
130
|
}
|
|
103
131
|
let targets;
|
|
104
132
|
try {
|
|
105
|
-
targets = await resolveTargets(
|
|
133
|
+
targets = await resolveTargets(scanRoot, {
|
|
134
|
+
scanAll: options.scanAll,
|
|
135
|
+
skipNodeModules: options.skipNodeModules,
|
|
136
|
+
});
|
|
106
137
|
}
|
|
107
138
|
catch (error) {
|
|
108
139
|
console.error(error.message);
|
|
109
|
-
return
|
|
140
|
+
return 4;
|
|
110
141
|
}
|
|
111
142
|
if (options.dryRun) {
|
|
112
143
|
if (!options.quiet) {
|
|
@@ -126,7 +157,7 @@ export async function runScan(inputPath, options) {
|
|
|
126
157
|
}
|
|
127
158
|
catch (error) {
|
|
128
159
|
console.error(error.message);
|
|
129
|
-
return
|
|
160
|
+
return 4;
|
|
130
161
|
}
|
|
131
162
|
}
|
|
132
163
|
const resolvedApiKey = resolveApiKey({
|
|
@@ -134,26 +165,36 @@ export async function runScan(inputPath, options) {
|
|
|
134
165
|
env: process.env,
|
|
135
166
|
config,
|
|
136
167
|
});
|
|
137
|
-
|
|
168
|
+
const useAnonymous = !resolvedApiKey;
|
|
169
|
+
if (!useAnonymous && !resolvedApiKey) {
|
|
138
170
|
console.error("No API key found. Run 'skillguard init' or set SKILLGUARD_API_KEY.");
|
|
139
|
-
return
|
|
171
|
+
return 4;
|
|
140
172
|
}
|
|
141
173
|
const color = shouldUseColor(options.noColor);
|
|
142
174
|
const results = [];
|
|
175
|
+
if (useAnonymous && !options.quiet && !options.json) {
|
|
176
|
+
console.log('Scanning anonymously (limited). Add API key for higher limits.');
|
|
177
|
+
}
|
|
143
178
|
for (const filePath of targets) {
|
|
144
179
|
const content = await readFile(filePath, 'utf8');
|
|
145
180
|
let apiResponse;
|
|
146
181
|
try {
|
|
147
|
-
apiResponse =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
182
|
+
apiResponse = useAnonymous
|
|
183
|
+
? await scanContentAnonymous({
|
|
184
|
+
baseUrl,
|
|
185
|
+
content,
|
|
186
|
+
timeoutMs: options.timeoutMs,
|
|
187
|
+
})
|
|
188
|
+
: await scanContent({
|
|
189
|
+
baseUrl,
|
|
190
|
+
apiKey: resolvedApiKey.apiKey,
|
|
191
|
+
content,
|
|
192
|
+
timeoutMs: options.timeoutMs,
|
|
193
|
+
});
|
|
153
194
|
}
|
|
154
195
|
catch (error) {
|
|
155
196
|
console.error(error.message);
|
|
156
|
-
return
|
|
197
|
+
return 5;
|
|
157
198
|
}
|
|
158
199
|
const findings = normalizeFindings(apiResponse.findings);
|
|
159
200
|
const score = toScanScore(apiResponse.score || apiResponse.overallRisk);
|
|
@@ -167,14 +208,23 @@ export async function runScan(inputPath, options) {
|
|
|
167
208
|
signatureStatus,
|
|
168
209
|
});
|
|
169
210
|
}
|
|
170
|
-
const
|
|
211
|
+
const failOn = options.failOn || 'warning';
|
|
212
|
+
const mode = options.failOnExplicit ? 'threshold' : 'worst';
|
|
213
|
+
const worstScore = resolveWorstScore(results);
|
|
214
|
+
const failedFiles = results.filter((result) => shouldFail(result.score, failOn)).map((result) => result.file);
|
|
215
|
+
const exitCode = mode === 'threshold'
|
|
216
|
+
? (failedFiles.length > 0 ? 1 : 0)
|
|
217
|
+
: WORST_EXIT_CODES[worstScore];
|
|
171
218
|
if (!options.quiet) {
|
|
172
219
|
if (options.json) {
|
|
173
220
|
const summary = summarizeScores(results);
|
|
174
221
|
console.log(JSON.stringify({
|
|
175
|
-
cli_version:
|
|
222
|
+
cli_version: CLI_VERSION,
|
|
176
223
|
scanned_at: new Date().toISOString(),
|
|
177
|
-
|
|
224
|
+
mode,
|
|
225
|
+
worst_score: worstScore,
|
|
226
|
+
exit_code: exitCode,
|
|
227
|
+
fail_on: mode === 'threshold' ? failOn : undefined,
|
|
178
228
|
summary,
|
|
179
229
|
failed_files: failedFiles,
|
|
180
230
|
results,
|
|
@@ -185,9 +235,9 @@ export async function runScan(inputPath, options) {
|
|
|
185
235
|
console.log(renderSingleScan(result, color));
|
|
186
236
|
}
|
|
187
237
|
const summary = summarizeScores(results);
|
|
188
|
-
const rootLabel = targets.length === 1 ? targets[0] :
|
|
238
|
+
const rootLabel = targets.length === 1 ? targets[0] : scanRoot;
|
|
189
239
|
console.log(renderSummary(summary, rootLabel, color, failedFiles));
|
|
190
240
|
}
|
|
191
241
|
}
|
|
192
|
-
return
|
|
242
|
+
return exitCode;
|
|
193
243
|
}
|
package/dist/commands/verify.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function runVerify(inputPath, options) {
|
|
|
29
29
|
}
|
|
30
30
|
catch (error) {
|
|
31
31
|
console.error(error.message);
|
|
32
|
-
return
|
|
32
|
+
return 4;
|
|
33
33
|
}
|
|
34
34
|
let rawContent;
|
|
35
35
|
try {
|
|
@@ -37,7 +37,7 @@ export async function runVerify(inputPath, options) {
|
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
39
|
console.error('Could not read file.');
|
|
40
|
-
return
|
|
40
|
+
return 4;
|
|
41
41
|
}
|
|
42
42
|
const parsedSkill = parseSkillContent(rawContent);
|
|
43
43
|
if (!parsedSkill.security || typeof parsedSkill.security.signature !== 'string') {
|
|
@@ -59,7 +59,7 @@ export async function runVerify(inputPath, options) {
|
|
|
59
59
|
}
|
|
60
60
|
catch (error) {
|
|
61
61
|
console.error(error.message);
|
|
62
|
-
return
|
|
62
|
+
return 5;
|
|
63
63
|
}
|
|
64
64
|
const verification = verifySignature({ parsedSkill, jwks });
|
|
65
65
|
const valid = verification.signatureValid && verification.hashMatches && !verification.expired;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ScanScore } from '../lib/api.js';
|
|
2
|
+
export interface WorkflowOptions {
|
|
3
|
+
outputPath?: string;
|
|
4
|
+
scanPath: string;
|
|
5
|
+
failOn: ScanScore;
|
|
6
|
+
print: boolean;
|
|
7
|
+
force: boolean;
|
|
8
|
+
autoGhPush: boolean;
|
|
9
|
+
commitMessage?: string;
|
|
10
|
+
}
|
|
11
|
+
interface GitCommandResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
status: number;
|
|
15
|
+
}
|
|
16
|
+
type GitRunner = (args: string[]) => GitCommandResult;
|
|
17
|
+
export declare function runWorkflow(options: WorkflowOptions, runtime?: {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
gitRunner?: GitRunner;
|
|
20
|
+
}): Promise<number>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdir, access, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
const DEFAULT_WORKFLOW_PATH = '.github/workflows/skillguard.yml';
|
|
6
|
+
const DEFAULT_COMMIT_MESSAGE = 'chore(ci): add skillguard workflow';
|
|
7
|
+
function buildWorkflowYaml(input) {
|
|
8
|
+
const scanPath = input.scanPath.trim() || '.';
|
|
9
|
+
return [
|
|
10
|
+
'name: SkillGuard Gate',
|
|
11
|
+
'on:',
|
|
12
|
+
' pull_request:',
|
|
13
|
+
' paths:',
|
|
14
|
+
" - '**/SKILL.md'",
|
|
15
|
+
" - '**/skill.md'",
|
|
16
|
+
' push:',
|
|
17
|
+
' paths:',
|
|
18
|
+
" - '**/SKILL.md'",
|
|
19
|
+
" - '**/skill.md'",
|
|
20
|
+
'',
|
|
21
|
+
'jobs:',
|
|
22
|
+
' scan:',
|
|
23
|
+
' runs-on: ubuntu-latest',
|
|
24
|
+
' steps:',
|
|
25
|
+
' - uses: actions/checkout@v4',
|
|
26
|
+
' - uses: actions/setup-node@v4',
|
|
27
|
+
' with:',
|
|
28
|
+
" node-version: '20'",
|
|
29
|
+
' - name: SkillGuard scan',
|
|
30
|
+
` run: npx @skillguard/cli gate ${scanPath} --fail-on ${input.failOn}`,
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
function isCommandSafePath(value) {
|
|
34
|
+
return /^[./a-zA-Z0-9_-]+$/.test(value);
|
|
35
|
+
}
|
|
36
|
+
async function fileExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
await access(path, fsConstants.F_OK);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function createDefaultGitRunner(cwd) {
|
|
46
|
+
return (args) => {
|
|
47
|
+
const result = spawnSync('git', args, {
|
|
48
|
+
cwd,
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
if (result.error) {
|
|
53
|
+
throw new Error('Git command failed.');
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
57
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : '',
|
|
58
|
+
status: typeof result.status === 'number' ? result.status : 1,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function ensureGitSuccess(result, fallbackMessage) {
|
|
63
|
+
if (result.status === 0) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const stderr = result.stderr.trim();
|
|
67
|
+
if (stderr) {
|
|
68
|
+
throw new Error(stderr);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(fallbackMessage);
|
|
71
|
+
}
|
|
72
|
+
function resolveWorkflowPath(cwd, outputPath) {
|
|
73
|
+
return resolve(cwd, outputPath || DEFAULT_WORKFLOW_PATH);
|
|
74
|
+
}
|
|
75
|
+
function commitAndPushWorkflow(input) {
|
|
76
|
+
const git = input.gitRunner || createDefaultGitRunner(input.cwd);
|
|
77
|
+
const repoRootResult = git(['rev-parse', '--show-toplevel']);
|
|
78
|
+
ensureGitSuccess(repoRootResult, 'Not a git repository.');
|
|
79
|
+
const repoRoot = repoRootResult.stdout.trim();
|
|
80
|
+
const relativePath = relative(repoRoot, input.workflowPath);
|
|
81
|
+
if (!relativePath || relativePath.startsWith('..')) {
|
|
82
|
+
throw new Error('Workflow path must be inside the current git repository.');
|
|
83
|
+
}
|
|
84
|
+
ensureGitSuccess(git(['add', '--', relativePath]), 'Could not stage workflow file.');
|
|
85
|
+
const stagedResult = git(['diff', '--cached', '--name-only', '--', relativePath]);
|
|
86
|
+
ensureGitSuccess(stagedResult, 'Could not inspect staged changes.');
|
|
87
|
+
if (!stagedResult.stdout.trim()) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
ensureGitSuccess(git(['commit', '-m', input.commitMessage, '--', relativePath]), 'Could not create commit.');
|
|
91
|
+
ensureGitSuccess(git(['push']), 'Could not push commit.');
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
export async function runWorkflow(options, runtime) {
|
|
95
|
+
const cwd = runtime?.cwd || process.cwd();
|
|
96
|
+
const scanPath = (options.scanPath || '.').trim() || '.';
|
|
97
|
+
if (!isCommandSafePath(scanPath)) {
|
|
98
|
+
console.error('Invalid --scan-path value. Use simple relative paths only.');
|
|
99
|
+
return 4;
|
|
100
|
+
}
|
|
101
|
+
if (options.print && options.autoGhPush) {
|
|
102
|
+
console.error('Cannot use --auto-gh-push with --print.');
|
|
103
|
+
return 4;
|
|
104
|
+
}
|
|
105
|
+
const yaml = buildWorkflowYaml({
|
|
106
|
+
scanPath,
|
|
107
|
+
failOn: options.failOn,
|
|
108
|
+
});
|
|
109
|
+
if (options.print) {
|
|
110
|
+
console.log(yaml);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
const workflowPath = resolveWorkflowPath(cwd, options.outputPath);
|
|
114
|
+
const exists = await fileExists(workflowPath);
|
|
115
|
+
if (exists && !options.force) {
|
|
116
|
+
console.error(`Workflow file already exists: ${workflowPath}. Use --force to overwrite.`);
|
|
117
|
+
return 4;
|
|
118
|
+
}
|
|
119
|
+
await mkdir(dirname(workflowPath), { recursive: true });
|
|
120
|
+
await writeFile(workflowPath, `${yaml}\n`, 'utf8');
|
|
121
|
+
console.log(`Workflow written to ${workflowPath}`);
|
|
122
|
+
if (!options.autoGhPush) {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const pushed = commitAndPushWorkflow({
|
|
127
|
+
cwd,
|
|
128
|
+
workflowPath,
|
|
129
|
+
commitMessage: (options.commitMessage || DEFAULT_COMMIT_MESSAGE).trim() || DEFAULT_COMMIT_MESSAGE,
|
|
130
|
+
gitRunner: runtime?.gitRunner,
|
|
131
|
+
});
|
|
132
|
+
if (!pushed) {
|
|
133
|
+
console.log('Workflow already up to date. No commit created.');
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error(`Auto push failed: ${error.message}`);
|
|
139
|
+
return 5;
|
|
140
|
+
}
|
|
141
|
+
console.log('Workflow committed and pushed.');
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -14,6 +14,34 @@ export interface ScanApiResponse {
|
|
|
14
14
|
signedSkillContent?: string;
|
|
15
15
|
[key: string]: unknown;
|
|
16
16
|
}
|
|
17
|
+
export interface LimitsApiResponse {
|
|
18
|
+
mode?: 'anonymous' | 'owner' | 'agent';
|
|
19
|
+
tokenBalance?: number;
|
|
20
|
+
ownerTrial?: {
|
|
21
|
+
scansLimit?: number;
|
|
22
|
+
scansUsed?: number;
|
|
23
|
+
remainingScans?: number;
|
|
24
|
+
expiresAt?: string | null;
|
|
25
|
+
active?: boolean;
|
|
26
|
+
};
|
|
27
|
+
apiKeyQuota?: {
|
|
28
|
+
keyType?: 'standard' | 'trial';
|
|
29
|
+
maxScans?: number | null;
|
|
30
|
+
scansUsed?: number;
|
|
31
|
+
remainingScans?: number | null;
|
|
32
|
+
expiresAt?: string | null;
|
|
33
|
+
unlimited?: boolean;
|
|
34
|
+
active?: boolean;
|
|
35
|
+
};
|
|
36
|
+
anonymousQuota?: {
|
|
37
|
+
windowMs?: number;
|
|
38
|
+
maxScans?: number;
|
|
39
|
+
scansUsed?: number;
|
|
40
|
+
remainingScans?: number;
|
|
41
|
+
resetAt?: string;
|
|
42
|
+
};
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
17
45
|
export interface SanitizedApiError {
|
|
18
46
|
message: string;
|
|
19
47
|
exitCode: 2 | 3;
|
|
@@ -26,6 +54,18 @@ export declare function scanContent(input: {
|
|
|
26
54
|
timeoutMs: number;
|
|
27
55
|
fetchImpl?: typeof fetch;
|
|
28
56
|
}): Promise<ScanApiResponse>;
|
|
57
|
+
export declare function scanContentAnonymous(input: {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
content: string;
|
|
60
|
+
timeoutMs: number;
|
|
61
|
+
fetchImpl?: typeof fetch;
|
|
62
|
+
}): Promise<ScanApiResponse>;
|
|
63
|
+
export declare function fetchLimits(input: {
|
|
64
|
+
baseUrl: string;
|
|
65
|
+
timeoutMs: number;
|
|
66
|
+
apiKey?: string;
|
|
67
|
+
fetchImpl?: typeof fetch;
|
|
68
|
+
}): Promise<LimitsApiResponse>;
|
|
29
69
|
export declare function fetchJwks(input: {
|
|
30
70
|
baseUrl: string;
|
|
31
71
|
timeoutMs: number;
|
package/dist/lib/api.js
CHANGED
|
@@ -58,6 +58,47 @@ function mapStatusMessage(status) {
|
|
|
58
58
|
}
|
|
59
59
|
return 'SkillGuard API request failed.';
|
|
60
60
|
}
|
|
61
|
+
function mapLimitsStatusMessage(status) {
|
|
62
|
+
if (status === 401) {
|
|
63
|
+
return "Invalid API key. Run 'skillguard init' or set SKILLGUARD_API_KEY.";
|
|
64
|
+
}
|
|
65
|
+
if (status === 403) {
|
|
66
|
+
return 'Access denied for this API key.';
|
|
67
|
+
}
|
|
68
|
+
if (status === 429) {
|
|
69
|
+
return 'Rate limit exceeded. Wait and retry.';
|
|
70
|
+
}
|
|
71
|
+
if (status >= 500) {
|
|
72
|
+
return 'SkillGuard API error. Try again later.';
|
|
73
|
+
}
|
|
74
|
+
return 'SkillGuard limits request failed.';
|
|
75
|
+
}
|
|
76
|
+
function detectCiSource(env = process.env) {
|
|
77
|
+
if (env.GITHUB_ACTIONS === 'true')
|
|
78
|
+
return 'github-actions';
|
|
79
|
+
if (env.GITLAB_CI === 'true')
|
|
80
|
+
return 'gitlab';
|
|
81
|
+
if (env.BUILDKITE === 'true')
|
|
82
|
+
return 'buildkite';
|
|
83
|
+
if (env.CIRCLECI === 'true')
|
|
84
|
+
return 'circleci';
|
|
85
|
+
if (env.JENKINS_URL)
|
|
86
|
+
return 'jenkins';
|
|
87
|
+
if (env.TF_BUILD === 'True' || env.AZURE_HTTP_USER_AGENT)
|
|
88
|
+
return 'azure-pipelines';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function buildCliHeaders(extra = {}) {
|
|
92
|
+
const headers = {
|
|
93
|
+
'X-SkillGuard-Source': 'cli',
|
|
94
|
+
...extra,
|
|
95
|
+
};
|
|
96
|
+
const ciSource = detectCiSource();
|
|
97
|
+
if (ciSource) {
|
|
98
|
+
headers['X-SkillGuard-CI'] = ciSource;
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
61
102
|
export async function scanContent(input) {
|
|
62
103
|
const fetchFn = input.fetchImpl || fetch;
|
|
63
104
|
const endpoint = `${input.baseUrl}/api/v1/scan`;
|
|
@@ -66,6 +107,7 @@ export async function scanContent(input) {
|
|
|
66
107
|
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
67
108
|
method: 'POST',
|
|
68
109
|
headers: {
|
|
110
|
+
...buildCliHeaders(),
|
|
69
111
|
'X-API-Key': input.apiKey,
|
|
70
112
|
'Content-Type': 'application/json',
|
|
71
113
|
},
|
|
@@ -101,6 +143,91 @@ export async function scanContent(input) {
|
|
|
101
143
|
timeout.cancel();
|
|
102
144
|
}
|
|
103
145
|
}
|
|
146
|
+
export async function scanContentAnonymous(input) {
|
|
147
|
+
const fetchFn = input.fetchImpl || fetch;
|
|
148
|
+
const endpoint = `${input.baseUrl}/api/v1/scan/anonymous`;
|
|
149
|
+
const timeout = withTimeout(input.timeoutMs);
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
...buildCliHeaders(),
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify({ content: input.content }),
|
|
158
|
+
signal: timeout.signal,
|
|
159
|
+
}, 1);
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(mapStatusMessage(response.status));
|
|
162
|
+
}
|
|
163
|
+
const parsed = await parseJsonSafe(response);
|
|
164
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
165
|
+
throw new Error('Unexpected API response.');
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const typed = error;
|
|
171
|
+
if (typed.name === 'AbortError') {
|
|
172
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
173
|
+
}
|
|
174
|
+
if (typed.message === 'Unexpected API response.') {
|
|
175
|
+
throw typed;
|
|
176
|
+
}
|
|
177
|
+
if (typed.message.includes('Rate limit exceeded.') ||
|
|
178
|
+
typed.message.includes('SkillGuard API error.') ||
|
|
179
|
+
typed.message.includes('SkillGuard API request failed.')) {
|
|
180
|
+
throw typed;
|
|
181
|
+
}
|
|
182
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
timeout.cancel();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export async function fetchLimits(input) {
|
|
189
|
+
const fetchFn = input.fetchImpl || fetch;
|
|
190
|
+
const endpoint = `${input.baseUrl}/api/v1/limits`;
|
|
191
|
+
const timeout = withTimeout(input.timeoutMs);
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
194
|
+
method: 'GET',
|
|
195
|
+
headers: {
|
|
196
|
+
...buildCliHeaders(),
|
|
197
|
+
...(input.apiKey ? { 'X-API-Key': input.apiKey } : {}),
|
|
198
|
+
},
|
|
199
|
+
signal: timeout.signal,
|
|
200
|
+
}, 1);
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
throw new Error(mapLimitsStatusMessage(response.status));
|
|
203
|
+
}
|
|
204
|
+
const parsed = await parseJsonSafe(response);
|
|
205
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
206
|
+
throw new Error('Unexpected API response.');
|
|
207
|
+
}
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
const typed = error;
|
|
212
|
+
if (typed.name === 'AbortError') {
|
|
213
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
214
|
+
}
|
|
215
|
+
if (typed.message === 'Unexpected API response.') {
|
|
216
|
+
throw typed;
|
|
217
|
+
}
|
|
218
|
+
if (typed.message.includes('Invalid API key.') ||
|
|
219
|
+
typed.message.includes('Access denied') ||
|
|
220
|
+
typed.message.includes('Rate limit exceeded.') ||
|
|
221
|
+
typed.message.includes('SkillGuard API error.') ||
|
|
222
|
+
typed.message.includes('SkillGuard limits request failed.')) {
|
|
223
|
+
throw typed;
|
|
224
|
+
}
|
|
225
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
timeout.cancel();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
104
231
|
export async function fetchJwks(input) {
|
|
105
232
|
const fetchFn = input.fetchImpl || fetch;
|
|
106
233
|
const endpoint = `${input.baseUrl}/.well-known/skillguard-jwks.json`;
|
package/dist/lib/format.js
CHANGED
|
@@ -113,6 +113,8 @@ export function renderSummary(summary, rootPath, color, failedFiles) {
|
|
|
113
113
|
` ${formatCell(maybeColor(color, GREEN, 'safe:'), 11)}${String(summary.safe)}`,
|
|
114
114
|
` ${formatCell(maybeColor(color, YELLOW, 'warning:'), 11)}${String(summary.warning)}`,
|
|
115
115
|
` ${formatCell(maybeColor(color, RED, 'dangerous:'), 11)}${String(summary.dangerous)}`,
|
|
116
|
+
'',
|
|
117
|
+
`Worst score: ${summary.dangerous > 0 ? 'dangerous' : summary.warning > 0 ? 'warning' : 'safe'}`,
|
|
116
118
|
];
|
|
117
119
|
if (failedFiles.length > 0) {
|
|
118
120
|
lines.push('');
|