@skillguard/cli 0.1.1 → 0.2.1

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Added `skillguard scan` without path argument (defaults to recursive scan from current directory)
6
+ - Added recursive skip controls: `--skip-node-modules` and `--scan-all`
7
+ - Added worst-score default exit mode for scan: `safe=0`, `warning=1`, `dangerous=2`
8
+ - Preserved legacy threshold mode when `--fail-on` is explicitly provided
9
+ - Added JSON scan output fields: `worst_score`, `exit_code`, `mode`
10
+ - Updated non-scan failure exits across commands: usage/input/config -> `4`, network/API/runtime -> `5`
11
+
3
12
  ## 0.1.0
4
13
 
5
14
  - Initial release of `@skillguard/cli`
package/README.md CHANGED
@@ -8,6 +8,12 @@ Security scanner CLI for AI agent `SKILL.md` files.
8
8
  npx @skillguard/cli scan SKILL.md
9
9
  ```
10
10
 
11
+ ### Scan all skills in current repo
12
+
13
+ ```bash
14
+ npx @skillguard/cli scan
15
+ ```
16
+
11
17
  ## Setup
12
18
 
13
19
  ```bash
@@ -40,6 +46,13 @@ npx @skillguard/cli scan ./skills --fail-on warning
40
46
  npx @skillguard/cli scan ./skills --json > skillguard-report.json
41
47
  ```
42
48
 
49
+ ### Worst-score CI exit code (recommended)
50
+
51
+ ```bash
52
+ npx @skillguard/cli scan
53
+ # exit: safe=0, warning=1, dangerous=2
54
+ ```
55
+
43
56
  ### Verify signature
44
57
 
45
58
  ```bash
@@ -51,13 +64,15 @@ npx @skillguard/cli verify ./SKILL.md
51
64
  ### scan
52
65
 
53
66
  - `--json`
54
- - `--fail-on <safe|warning|dangerous>` (default: `warning`)
67
+ - `--fail-on <safe|warning|dangerous>` (optional, enables legacy threshold mode)
55
68
  - `--timeout <ms>` (default: `30000`)
56
69
  - `--base-url <url>` (default: `https://skillguard.ai`)
57
70
  - `--api-key <key>`
58
71
  - `--dry-run`
59
72
  - `--quiet`
60
73
  - `--no-color`
74
+ - `--skip-node-modules` (default: enabled)
75
+ - `--scan-all` (disable skip filters, scans everything recursively)
61
76
 
62
77
  ### verify
63
78
 
@@ -67,10 +82,19 @@ npx @skillguard/cli verify ./SKILL.md
67
82
 
68
83
  ## Exit codes
69
84
 
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
85
+ - `scan` default mode (no `--fail-on`):
86
+ - `0` worst score is `safe`
87
+ - `1` worst score is `warning`
88
+ - `2` worst score is `dangerous`
89
+ - `scan` threshold mode (`--fail-on` provided):
90
+ - `0` below threshold
91
+ - `1` threshold exceeded
92
+ - `verify`:
93
+ - `0` signature valid
94
+ - `1` invalid/tampered/expired signature
95
+ - all commands:
96
+ - `4` usage/input/config error
97
+ - `5` network/API/runtime external failure
74
98
 
75
99
  ## GitHub Actions example
76
100
 
package/dist/cli.js CHANGED
@@ -3,13 +3,13 @@ import { parseArgs } from 'node:util';
3
3
  import { runInit } from './commands/init.js';
4
4
  import { runScan } from './commands/scan.js';
5
5
  import { runVerify } from './commands/verify.js';
6
- const VERSION = '0.1.0';
6
+ const VERSION = '0.2.0';
7
7
  function printUsage() {
8
8
  console.log(`SkillGuard CLI ${VERSION}
9
9
 
10
10
  Usage:
11
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]
12
+ skillguard scan [path] [--json] [--fail-on <safe|warning|dangerous>] [--timeout <ms>] [--base-url <url>] [--api-key <key>] [--dry-run] [--quiet] [--no-color] [--skip-node-modules] [--scan-all]
13
13
  skillguard verify <path> [--json] [--timeout <ms>] [--base-url <url>]
14
14
 
15
15
  Options:
@@ -48,6 +48,8 @@ function parseShared(args) {
48
48
  quiet: { type: 'boolean' },
49
49
  'no-color': { type: 'boolean' },
50
50
  'fail-on': { type: 'string' },
51
+ 'scan-all': { type: 'boolean' },
52
+ 'skip-node-modules': { type: 'boolean' },
51
53
  timeout: { type: 'string' },
52
54
  'base-url': { type: 'string' },
53
55
  'api-key': { type: 'string' },
@@ -59,6 +61,9 @@ function parseShared(args) {
59
61
  positionals: parsed.positionals,
60
62
  };
61
63
  }
64
+ function isFailOnExplicit(args) {
65
+ return args.some((arg) => arg === '--fail-on' || arg.startsWith('--fail-on='));
66
+ }
62
67
  async function run() {
63
68
  const argv = process.argv.slice(2);
64
69
  const command = argv[0];
@@ -87,23 +92,24 @@ async function run() {
87
92
  }
88
93
  if (command === 'scan') {
89
94
  const pathArg = positionals[0];
90
- if (!pathArg) {
91
- console.error('Missing required <path> argument.');
92
- return 2;
93
- }
94
95
  let parsedTimeout;
95
96
  let failOn;
96
97
  try {
97
98
  parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
98
- failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
99
+ if (isFailOnExplicit(argv.slice(1))) {
100
+ failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
101
+ }
99
102
  }
100
103
  catch (error) {
101
104
  console.error(error.message);
102
- return 2;
105
+ return 4;
103
106
  }
104
107
  const scanOptions = {
105
108
  json: options.json === true,
106
109
  failOn,
110
+ failOnExplicit: isFailOnExplicit(argv.slice(1)),
111
+ scanAll: options['scan-all'] === true,
112
+ skipNodeModules: options['scan-all'] === true ? false : options['skip-node-modules'] !== false,
107
113
  timeoutMs: parsedTimeout,
108
114
  baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
109
115
  apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
@@ -117,7 +123,7 @@ async function run() {
117
123
  const pathArg = positionals[0];
118
124
  if (!pathArg) {
119
125
  console.error('Missing required <path> argument.');
120
- return 2;
126
+ return 4;
121
127
  }
122
128
  let parsedTimeout;
123
129
  try {
@@ -125,7 +131,7 @@ async function run() {
125
131
  }
126
132
  catch (error) {
127
133
  console.error(error.message);
128
- return 2;
134
+ return 4;
129
135
  }
130
136
  const verifyOptions = {
131
137
  baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
@@ -136,7 +142,7 @@ async function run() {
136
142
  }
137
143
  console.error(`Unknown command: ${command}`);
138
144
  printUsage();
139
- return 2;
145
+ return 4;
140
146
  }
141
147
  run()
142
148
  .then((code) => {
@@ -144,5 +150,5 @@ run()
144
150
  })
145
151
  .catch(() => {
146
152
  console.error('Unexpected CLI error.');
147
- process.exitCode = 3;
153
+ process.exitCode = 5;
148
154
  });
@@ -1,7 +1,10 @@
1
1
  import { type ScanFinding, type ScanScore } from '../lib/api.js';
2
2
  export interface ScanOptions {
3
3
  json: boolean;
4
- failOn: ScanScore;
4
+ failOn?: ScanScore;
5
+ failOnExplicit: boolean;
6
+ scanAll: boolean;
7
+ skipNodeModules: boolean;
5
8
  timeoutMs: number;
6
9
  baseUrl?: string;
7
10
  apiKey?: string;
@@ -15,4 +18,4 @@ export interface ScanResult {
15
18
  findings: ScanFinding[];
16
19
  signatureStatus: string;
17
20
  }
18
- export declare function runScan(inputPath: string, options: ScanOptions): Promise<number>;
21
+ export declare function runScan(inputPath: string | undefined, options: ScanOptions): Promise<number>;
@@ -8,12 +8,26 @@ const FAIL_LEVELS = {
8
8
  warning: 1,
9
9
  dangerous: 2,
10
10
  };
11
+ const WORST_EXIT_CODES = {
12
+ safe: 0,
13
+ warning: 1,
14
+ dangerous: 2,
15
+ };
11
16
  function shouldFail(score, failOn) {
12
17
  if (failOn === 'safe') {
13
18
  return score !== 'safe';
14
19
  }
15
20
  return FAIL_LEVELS[score] >= FAIL_LEVELS[failOn];
16
21
  }
22
+ function resolveWorstScore(results) {
23
+ if (results.some((result) => result.score === 'dangerous')) {
24
+ return 'dangerous';
25
+ }
26
+ if (results.some((result) => result.score === 'warning')) {
27
+ return 'warning';
28
+ }
29
+ return 'safe';
30
+ }
17
31
  function isForbiddenFilePath(filePath) {
18
32
  const name = basename(filePath).toLowerCase();
19
33
  return (name === '.env' ||
@@ -28,7 +42,16 @@ function isWithinRoot(rootPath, candidatePath) {
28
42
  }
29
43
  return candidatePath.startsWith(`${rootPath}${sep}`);
30
44
  }
31
- async function findSkillFiles(rootPath) {
45
+ function shouldSkipDirectory(entryName, options) {
46
+ if (options.scanAll) {
47
+ return false;
48
+ }
49
+ if (options.skipNodeModules && entryName.toLowerCase() === 'node_modules') {
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ async function findSkillFiles(rootPath, options) {
32
55
  const rootRealPath = await realpath(rootPath);
33
56
  const queue = [rootPath];
34
57
  const found = new Set();
@@ -44,6 +67,9 @@ async function findSkillFiles(rootPath) {
44
67
  continue;
45
68
  }
46
69
  if (entry.isDirectory()) {
70
+ if (shouldSkipDirectory(entry.name, options)) {
71
+ continue;
72
+ }
47
73
  const resolvedDir = await realpath(fullPath);
48
74
  if (!isWithinRoot(rootRealPath, resolvedDir)) {
49
75
  continue;
@@ -70,14 +96,14 @@ async function findSkillFiles(rootPath) {
70
96
  }
71
97
  return Array.from(found).sort();
72
98
  }
73
- async function resolveTargets(inputPath) {
99
+ async function resolveTargets(inputPath, traversalOptions) {
74
100
  const path = resolve(inputPath);
75
101
  const stat = await import('node:fs/promises').then((mod) => mod.lstat(path));
76
102
  if (stat.isSymbolicLink()) {
77
103
  throw new Error('Symlink paths are not supported.');
78
104
  }
79
105
  if (stat.isDirectory()) {
80
- const files = await findSkillFiles(path);
106
+ const files = await findSkillFiles(path, traversalOptions);
81
107
  if (files.length === 0) {
82
108
  throw new Error('No SKILL.md files found in directory.');
83
109
  }
@@ -92,21 +118,25 @@ async function resolveTargets(inputPath) {
92
118
  return [path];
93
119
  }
94
120
  export async function runScan(inputPath, options) {
121
+ const scanRoot = inputPath ? resolve(inputPath) : process.cwd();
95
122
  let baseUrl;
96
123
  try {
97
124
  baseUrl = normalizeBaseUrl(options.baseUrl);
98
125
  }
99
126
  catch (error) {
100
127
  console.error(error.message);
101
- return 2;
128
+ return 4;
102
129
  }
103
130
  let targets;
104
131
  try {
105
- targets = await resolveTargets(inputPath);
132
+ targets = await resolveTargets(scanRoot, {
133
+ scanAll: options.scanAll,
134
+ skipNodeModules: options.skipNodeModules,
135
+ });
106
136
  }
107
137
  catch (error) {
108
138
  console.error(error.message);
109
- return 2;
139
+ return 4;
110
140
  }
111
141
  if (options.dryRun) {
112
142
  if (!options.quiet) {
@@ -126,7 +156,7 @@ export async function runScan(inputPath, options) {
126
156
  }
127
157
  catch (error) {
128
158
  console.error(error.message);
129
- return 2;
159
+ return 4;
130
160
  }
131
161
  }
132
162
  const resolvedApiKey = resolveApiKey({
@@ -136,7 +166,7 @@ export async function runScan(inputPath, options) {
136
166
  });
137
167
  if (!resolvedApiKey) {
138
168
  console.error("No API key found. Run 'skillguard init' or set SKILLGUARD_API_KEY.");
139
- return 2;
169
+ return 4;
140
170
  }
141
171
  const color = shouldUseColor(options.noColor);
142
172
  const results = [];
@@ -153,7 +183,7 @@ export async function runScan(inputPath, options) {
153
183
  }
154
184
  catch (error) {
155
185
  console.error(error.message);
156
- return 3;
186
+ return 5;
157
187
  }
158
188
  const findings = normalizeFindings(apiResponse.findings);
159
189
  const score = toScanScore(apiResponse.score || apiResponse.overallRisk);
@@ -167,14 +197,23 @@ export async function runScan(inputPath, options) {
167
197
  signatureStatus,
168
198
  });
169
199
  }
170
- const failedFiles = results.filter((result) => shouldFail(result.score, options.failOn)).map((result) => result.file);
200
+ const failOn = options.failOn || 'warning';
201
+ const mode = options.failOnExplicit ? 'threshold' : 'worst';
202
+ const worstScore = resolveWorstScore(results);
203
+ const failedFiles = results.filter((result) => shouldFail(result.score, failOn)).map((result) => result.file);
204
+ const exitCode = mode === 'threshold'
205
+ ? (failedFiles.length > 0 ? 1 : 0)
206
+ : WORST_EXIT_CODES[worstScore];
171
207
  if (!options.quiet) {
172
208
  if (options.json) {
173
209
  const summary = summarizeScores(results);
174
210
  console.log(JSON.stringify({
175
- cli_version: '0.1.0',
211
+ cli_version: '0.2.0',
176
212
  scanned_at: new Date().toISOString(),
177
- fail_on: options.failOn,
213
+ mode,
214
+ worst_score: worstScore,
215
+ exit_code: exitCode,
216
+ fail_on: mode === 'threshold' ? failOn : undefined,
178
217
  summary,
179
218
  failed_files: failedFiles,
180
219
  results,
@@ -185,9 +224,9 @@ export async function runScan(inputPath, options) {
185
224
  console.log(renderSingleScan(result, color));
186
225
  }
187
226
  const summary = summarizeScores(results);
188
- const rootLabel = targets.length === 1 ? targets[0] : resolve(inputPath);
227
+ const rootLabel = targets.length === 1 ? targets[0] : scanRoot;
189
228
  console.log(renderSummary(summary, rootLabel, color, failedFiles));
190
229
  }
191
230
  }
192
- return failedFiles.length > 0 ? 1 : 0;
231
+ return exitCode;
193
232
  }
@@ -29,7 +29,7 @@ export async function runVerify(inputPath, options) {
29
29
  }
30
30
  catch (error) {
31
31
  console.error(error.message);
32
- return 2;
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 2;
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 3;
62
+ return 5;
63
63
  }
64
64
  const verification = verifySignature({ parsedSkill, jwks });
65
65
  const valid = verification.signatureValid && verification.hashMatches && !verification.expired;
@@ -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('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillguard/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Security scanner for AI agent skill files",
5
5
  "type": "module",
6
6
  "bin": {