awguard 1.6.0 → 1.7.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/Dockerfile +8 -1
  3. package/README.md +176 -12
  4. package/action.yml +5 -1
  5. package/docs/comparison.md +161 -16
  6. package/docs/launch-plan.md +12 -2
  7. package/docs/marketplace-listing.md +19 -0
  8. package/docs/npm-publishing.md +68 -0
  9. package/docs/release-checklist.md +71 -0
  10. package/docs/report-gallery.md +166 -0
  11. package/docs/roadmap.md +32 -2
  12. package/docs/rule-authoring.md +99 -0
  13. package/docs/schemas.md +16 -0
  14. package/docs/setup-recipes.md +199 -0
  15. package/docs/site/index.html +29 -0
  16. package/examples/.vscode/tasks.json +17 -1
  17. package/examples/README.md +7 -0
  18. package/examples/awguard.config.example.json +8 -0
  19. package/examples/corpus/.cursor/rules/autonomy.mdc +3 -0
  20. package/examples/corpus/.github/prompts/auto-fix.prompt.md +3 -0
  21. package/examples/corpus/.github/workflows/agentic-pr-review.yml +20 -0
  22. package/examples/corpus/.github/workflows/pull-request-target-head.yml +13 -0
  23. package/examples/corpus/.mcp.json +15 -0
  24. package/examples/corpus/AGENTS.md +5 -0
  25. package/examples/corpus/README.md +23 -0
  26. package/examples/dashboard/README.md +55 -0
  27. package/examples/dashboard/index.html +313 -0
  28. package/examples/dashboard/sample-history.json +53 -0
  29. package/examples/lab/README.md +6 -0
  30. package/examples/pr-comment-bot.yml +43 -0
  31. package/examples/pull-request-target.yml +1 -1
  32. package/examples/safe-agent.yml +1 -1
  33. package/examples/unsafe-agent.yml +1 -1
  34. package/examples/vscode-extension/README.md +49 -0
  35. package/examples/vscode-extension/assets/problems-panel.svg +23 -0
  36. package/examples/vscode-extension/package.json +68 -0
  37. package/examples/vscode-extension/src/extension.js +116 -0
  38. package/package.json +2 -1
  39. package/schemas/awguard.badge.schema.json +25 -0
  40. package/schemas/awguard.baseline.schema.json +40 -0
  41. package/schemas/awguard.comparison.schema.json +146 -0
  42. package/schemas/awguard.config.schema.json +167 -0
  43. package/schemas/awguard.inventory.schema.json +124 -0
  44. package/schemas/awguard.report.schema.json +121 -0
  45. package/src/autofix.js +201 -0
  46. package/src/badges.js +63 -0
  47. package/src/baseline.js +77 -0
  48. package/src/cli.js +248 -6
  49. package/src/compare.js +60 -4
  50. package/src/config.js +31 -2
  51. package/src/demo.js +90 -0
  52. package/src/doctor.js +189 -0
  53. package/src/explain.js +147 -0
  54. package/src/init.js +4 -1
  55. package/src/policy-packs.js +99 -0
  56. package/src/policy-wizard.js +165 -0
  57. package/src/remediation.js +73 -1
  58. package/src/reporters.js +86 -3
  59. package/src/scanner.js +204 -5
  60. package/src/templates.js +132 -0
package/src/baseline.js CHANGED
@@ -18,6 +18,51 @@ export function applyBaseline(result, baseline) {
18
18
  };
19
19
  }
20
20
 
21
+ export function reviewBaseline(result, baseline) {
22
+ const currentByFingerprint = new Map(result.findings.map((finding) => [finding.fingerprint, finding]));
23
+ const baselineFindings = baseline.findings || [];
24
+ const known = [];
25
+ const resolved = [];
26
+
27
+ for (const baselineFinding of baselineFindings) {
28
+ const currentFinding = currentByFingerprint.get(baselineFinding.fingerprint);
29
+ if (currentFinding) {
30
+ known.push({
31
+ ...baselineFinding,
32
+ current: currentFinding
33
+ });
34
+ } else {
35
+ resolved.push(baselineFinding);
36
+ }
37
+ }
38
+
39
+ const baselineFingerprints = new Set(baselineFindings.map((finding) => finding.fingerprint));
40
+ const newFindings = result.findings.filter((finding) => !baselineFingerprints.has(finding.fingerprint));
41
+
42
+ return {
43
+ summary: {
44
+ baselineFindings: baselineFindings.length,
45
+ known: known.length,
46
+ resolved: resolved.length,
47
+ new: newFindings.length,
48
+ pruneRecommended: resolved.length > 0
49
+ },
50
+ known,
51
+ resolved,
52
+ newFindings
53
+ };
54
+ }
55
+
56
+ export function pruneBaseline(baseline, review) {
57
+ const knownFingerprints = new Set(review.known.map((finding) => finding.fingerprint));
58
+ return {
59
+ ...baseline,
60
+ generatedAt: new Date().toISOString(),
61
+ prunedAt: new Date().toISOString(),
62
+ findings: (baseline.findings || []).filter((finding) => knownFingerprints.has(finding.fingerprint))
63
+ };
64
+ }
65
+
21
66
  export function createBaseline(result) {
22
67
  return {
23
68
  version: 1,
@@ -55,6 +100,38 @@ export function writeBaseline(file, baseline) {
55
100
  return absoluteFile;
56
101
  }
57
102
 
103
+ export function renderBaselineReview(review, { format = 'text', baselineFile = '' } = {}) {
104
+ if (format === 'json') return JSON.stringify(review, null, 2);
105
+
106
+ const lines = ['Agentic Workflow Guard Baseline Review', ''];
107
+ if (baselineFile) lines.push(`Baseline: ${baselineFile}`);
108
+ lines.push(
109
+ `Known findings: ${review.summary.known}`,
110
+ `Resolved baseline entries: ${review.summary.resolved}`,
111
+ `New findings not in baseline: ${review.summary.new}`,
112
+ ''
113
+ );
114
+
115
+ if (review.summary.resolved > 0) {
116
+ lines.push('Resolved baseline entries that can be pruned:', '');
117
+ for (const finding of review.resolved) {
118
+ lines.push(`- ${finding.ruleId} ${finding.file}:${finding.line} ${finding.title}`);
119
+ }
120
+ lines.push('', 'Run with `--prune` to rewrite the baseline without resolved entries.', '');
121
+ } else {
122
+ lines.push('No stale baseline entries found.', '');
123
+ }
124
+
125
+ if (review.summary.new > 0) {
126
+ lines.push('New findings not covered by the baseline:', '');
127
+ for (const finding of review.newFindings) {
128
+ lines.push(`- ${finding.ruleId} ${finding.file}:${finding.line} ${finding.title}`);
129
+ }
130
+ }
131
+
132
+ return lines.join('\n').trimEnd();
133
+ }
134
+
58
135
  function summarizeBaseline(findings) {
59
136
  return findings.reduce(
60
137
  (summary, finding) => {
package/src/cli.js CHANGED
@@ -1,16 +1,33 @@
1
1
  import process from 'node:process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { applyBaseline, createBaseline, loadBaseline, writeBaseline } from './baseline.js';
5
- import { loadReport, renderComparison } from './compare.js';
4
+ import { applyAutofixPlan, buildAutofixPlan, renderAutofixPlan } from './autofix.js';
5
+ import {
6
+ applyBaseline,
7
+ createBaseline,
8
+ loadBaseline,
9
+ pruneBaseline,
10
+ renderBaselineReview,
11
+ reviewBaseline,
12
+ writeBaseline
13
+ } from './baseline.js';
14
+ import { renderBadgeSnippets } from './badges.js';
15
+ import { loadReport, renderComparison, renderComparisonJson } from './compare.js';
16
+ import { renderDemoWalkthrough } from './demo.js';
6
17
  import { loadConfig } from './config.js';
18
+ import { buildDoctorReport, renderDoctorReport } from './doctor.js';
19
+ import { renderRuleExplanation } from './explain.js';
7
20
  import { renderInitGuide } from './init.js';
21
+ import { renderPolicyPack } from './policy-packs.js';
22
+ import { buildPolicyWizard, renderPolicyWizard } from './policy-wizard.js';
8
23
  import { renderFixDryRun } from './remediation.js';
9
24
  import { scanWorkflows, severityRank } from './scanner.js';
25
+ import { renderTemplates } from './templates.js';
10
26
  import {
11
27
  renderBadge,
12
28
  renderGithubAnnotations,
13
29
  renderGraph,
30
+ renderGithubStepSummary,
14
31
  renderHtml,
15
32
  renderJson,
16
33
  renderMarkdown,
@@ -25,12 +42,28 @@ import {
25
42
  const HELP = `Agentic Workflow Guard
26
43
 
27
44
  Usage:
28
- awguard [path] [--config file] [--preset name] [--format text|json|markdown|github|sarif|graph|html|migration|score|badge|inventory|inventory-json] [--output file] [--baseline file] [--write-baseline file] [--fix-dry-run] [--fail-on none|low|medium|high|critical]
45
+ awguard [path] [--config file] [--preset name] [--format text|json|markdown|github|sarif|graph|html|migration|score|badge|inventory|inventory-json] [--output file] [--baseline file] [--write-baseline file] [--fix-dry-run] [--fix] [--fail-on none|low|medium|high|critical]
29
46
  awguard init
47
+ awguard doctor [path] [--config file] [--preset name]
48
+ awguard explain [AWG###]
49
+ awguard badges [--repo OWNER/REPO] [--branch main] [--badge-file docs/awguard-badge.json] [--site URL]
50
+ awguard demo
51
+ awguard templates [all|github|code-scanning|gitlab|pre-commit|vscode]
52
+ awguard policy-pack [oss|strict|enterprise]
53
+ awguard policy-wizard [path] [--config file] [--preset name] [--dry-run] [--format markdown|json] [--output file]
54
+ awguard baseline-review [path] --baseline file [--config file] [--preset name] [--format text|json] [--prune]
30
55
  awguard --compare previous.json current.json
31
56
 
32
57
  Examples:
33
58
  awguard init
59
+ awguard doctor
60
+ awguard explain AWG001
61
+ awguard badges --repo OWNER/REPO --site https://OWNER.github.io/REPO/
62
+ awguard demo
63
+ awguard templates github
64
+ awguard policy-pack strict
65
+ awguard policy-wizard . --dry-run
66
+ awguard baseline-review . --baseline awguard.baseline.json
34
67
  awguard .
35
68
  awguard .mcp.json
36
69
  awguard . --config awguard.config.json
@@ -43,6 +76,7 @@ Examples:
43
76
  awguard . --format score
44
77
  awguard . --format badge --output awguard-badge.json
45
78
  awguard . --fix-dry-run
79
+ awguard . --fix
46
80
  awguard . --format sarif --output awguard.sarif --fail-on none
47
81
  awguard . --write-baseline awguard.baseline.json
48
82
  awguard . --baseline awguard.baseline.json --fail-on high
@@ -56,6 +90,74 @@ export async function runCli(args, env = process.env) {
56
90
  return;
57
91
  }
58
92
 
93
+ if (args[0] === 'doctor') {
94
+ const options = parseArgs(args.slice(1), env);
95
+ const report = buildDoctorReport({
96
+ root: options.path,
97
+ configPath: options.config,
98
+ presets: options.presets,
99
+ env
100
+ });
101
+ console.log(renderDoctorReport(report));
102
+ if (report.status === 'fail') process.exitCode = 1;
103
+ return;
104
+ }
105
+
106
+ if (args[0] === 'explain') {
107
+ console.log(renderRuleExplanation(args[1]));
108
+ return;
109
+ }
110
+
111
+ if (args[0] === 'badges') {
112
+ console.log(renderBadgeSnippets(parseBadgeArgs(args.slice(1))));
113
+ return;
114
+ }
115
+
116
+ if (args[0] === 'demo') {
117
+ console.log(renderDemoWalkthrough());
118
+ return;
119
+ }
120
+
121
+ if (args[0] === 'templates') {
122
+ console.log(renderTemplates(args[1] || 'all'));
123
+ return;
124
+ }
125
+
126
+ if (args[0] === 'policy-pack') {
127
+ console.log(renderPolicyPack(args[1] || 'oss'));
128
+ return;
129
+ }
130
+
131
+ if (args[0] === 'policy-wizard') {
132
+ const options = parsePolicyWizardArgs(args.slice(1));
133
+ const loaded = loadConfig({ configPath: options.config, root: options.path, presets: options.presets });
134
+ const result = scanWorkflows({ root: options.path, config: loaded.config });
135
+ const wizard = buildPolicyWizard(result, { existingConfig: loaded.path ? JSON.parse(fs.readFileSync(loaded.path, 'utf8')) : {} });
136
+ const output = renderPolicyWizard(wizard, { format: options.format });
137
+ if (options.output && !options.dryRun) {
138
+ const outputFile = writeOutput(options.output, `${output}\n`);
139
+ console.error(`Wrote ${outputFile}`);
140
+ } else {
141
+ console.log(output);
142
+ }
143
+ return;
144
+ }
145
+
146
+ if (args[0] === 'baseline-review') {
147
+ const options = parseBaselineReviewArgs(args.slice(1));
148
+ if (!options.baseline) throw new Error('baseline-review requires --baseline file');
149
+ const { config } = loadConfig({ configPath: options.config, root: options.path, presets: options.presets });
150
+ const result = scanWorkflows({ root: options.path, config });
151
+ const baseline = loadBaseline(options.baseline);
152
+ const review = reviewBaseline(result, baseline);
153
+ if (options.prune) {
154
+ writeBaseline(options.baseline, pruneBaseline(baseline, review));
155
+ }
156
+ console.log(renderBaselineReview(review, { format: options.format, baselineFile: options.baseline }));
157
+ if (options.prune && options.format !== 'json') console.error(`Pruned baseline ${path.resolve(options.baseline)}`);
158
+ return;
159
+ }
160
+
59
161
  const options = parseArgs(args, env);
60
162
 
61
163
  if (options.help) {
@@ -64,7 +166,9 @@ export async function runCli(args, env = process.env) {
64
166
  }
65
167
 
66
168
  if (options.compare.length > 0) {
67
- const output = renderComparison(loadReport(options.compare[0]), loadReport(options.compare[1]));
169
+ const previous = loadReport(options.compare[0]);
170
+ const current = loadReport(options.compare[1]);
171
+ const output = options.format === 'json' ? renderComparisonJson(previous, current) : renderComparison(previous, current);
68
172
  if (options.output) {
69
173
  const outputFile = writeOutput(options.output, output);
70
174
  console.error(`Wrote ${outputFile}`);
@@ -86,15 +190,24 @@ export async function runCli(args, env = process.env) {
86
190
  console.error(`Wrote baseline ${baselineFile}`);
87
191
  }
88
192
 
89
- const output = options.fixDryRun ? renderFixDryRun(result) : render(result, options.format);
193
+ if (options.fix) {
194
+ const plan = applyAutofixPlan(buildAutofixPlan(result));
195
+ console.log(renderAutofixPlan(plan, { applied: true }));
196
+ return;
197
+ }
198
+
199
+ const output = options.fixDryRun ? renderFixDryRun(result, { autofixPlan: buildAutofixPlan(result) }) : render(result, options.format);
90
200
 
201
+ let outputFile = '';
91
202
  if (options.output) {
92
- const outputFile = writeOutput(options.output, output);
203
+ outputFile = writeOutput(options.output, output);
93
204
  console.error(`Wrote ${outputFile}`);
94
205
  } else if (output.trim().length > 0) {
95
206
  console.log(output);
96
207
  }
97
208
 
209
+ writeGithubStepSummary({ env, result, options, outputFile });
210
+
98
211
  const findingsToFailOn = result.findings.filter((finding) => finding.baselineState !== 'known');
99
212
  if (shouldFail(findingsToFailOn, options.failOn)) {
100
213
  process.exitCode = 1;
@@ -114,6 +227,7 @@ export function parseArgs(args, env = {}) {
114
227
  compare: [],
115
228
  presets: splitList(readInput(env, 'preset') || readInput(env, 'presets') || ''),
116
229
  fixDryRun: readBoolInput(env, 'fix_dry_run') || readBoolInput(env, 'fix-dry-run'),
230
+ fix: readBoolInput(env, 'fix'),
117
231
  help: false
118
232
  };
119
233
 
@@ -156,6 +270,8 @@ export function parseArgs(args, env = {}) {
156
270
  options.presets.push(...splitList(arg.slice('--preset='.length)));
157
271
  } else if (arg === '--fix-dry-run') {
158
272
  options.fixDryRun = true;
273
+ } else if (arg === '--fix') {
274
+ options.fix = true;
159
275
  } else if (!arg.startsWith('-')) {
160
276
  options.path = arg;
161
277
  } else {
@@ -217,6 +333,119 @@ function readBoolInput(env, name) {
217
333
  return value === 'true' || value === '1' || value === 'yes';
218
334
  }
219
335
 
336
+ function parsePolicyWizardArgs(args) {
337
+ const options = {
338
+ path: '.',
339
+ config: '',
340
+ presets: [],
341
+ dryRun: false,
342
+ format: 'markdown',
343
+ formatSpecified: false,
344
+ output: ''
345
+ };
346
+
347
+ for (let index = 0; index < args.length; index += 1) {
348
+ const arg = args[index];
349
+ if (arg === '--config') {
350
+ options.config = args[++index];
351
+ } else if (arg.startsWith('--config=')) {
352
+ options.config = arg.slice('--config='.length);
353
+ } else if (arg === '--preset') {
354
+ options.presets.push(...splitList(args[++index]));
355
+ } else if (arg.startsWith('--preset=')) {
356
+ options.presets.push(...splitList(arg.slice('--preset='.length)));
357
+ } else if (arg === '--dry-run') {
358
+ options.dryRun = true;
359
+ } else if (arg === '--format') {
360
+ options.format = args[++index];
361
+ options.formatSpecified = true;
362
+ } else if (arg.startsWith('--format=')) {
363
+ options.format = arg.slice('--format='.length);
364
+ options.formatSpecified = true;
365
+ } else if (arg === '--output') {
366
+ options.output = args[++index];
367
+ } else if (arg.startsWith('--output=')) {
368
+ options.output = arg.slice('--output='.length);
369
+ } else if (!arg.startsWith('-')) {
370
+ options.path = arg;
371
+ } else {
372
+ throw new Error(`unknown policy-wizard option: ${arg}`);
373
+ }
374
+ }
375
+
376
+ if (options.output && !options.formatSpecified) options.format = 'json';
377
+ validateEnum('policy-wizard format', options.format, ['markdown', 'json']);
378
+ return options;
379
+ }
380
+
381
+ function parseBaselineReviewArgs(args) {
382
+ const options = {
383
+ path: '.',
384
+ baseline: '',
385
+ config: '',
386
+ presets: [],
387
+ format: 'text',
388
+ prune: false
389
+ };
390
+
391
+ for (let index = 0; index < args.length; index += 1) {
392
+ const arg = args[index];
393
+ if (arg === '--baseline') {
394
+ options.baseline = args[++index];
395
+ } else if (arg.startsWith('--baseline=')) {
396
+ options.baseline = arg.slice('--baseline='.length);
397
+ } else if (arg === '--config') {
398
+ options.config = args[++index];
399
+ } else if (arg.startsWith('--config=')) {
400
+ options.config = arg.slice('--config='.length);
401
+ } else if (arg === '--preset') {
402
+ options.presets.push(...splitList(args[++index]));
403
+ } else if (arg.startsWith('--preset=')) {
404
+ options.presets.push(...splitList(arg.slice('--preset='.length)));
405
+ } else if (arg === '--format') {
406
+ options.format = args[++index];
407
+ } else if (arg.startsWith('--format=')) {
408
+ options.format = arg.slice('--format='.length);
409
+ } else if (arg === '--prune') {
410
+ options.prune = true;
411
+ } else if (!arg.startsWith('-')) {
412
+ options.path = arg;
413
+ } else {
414
+ throw new Error(`unknown baseline-review option: ${arg}`);
415
+ }
416
+ }
417
+
418
+ validateEnum('baseline-review format', options.format, ['text', 'json']);
419
+ return options;
420
+ }
421
+
422
+ function parseBadgeArgs(args) {
423
+ const options = {};
424
+ for (let index = 0; index < args.length; index += 1) {
425
+ const arg = args[index];
426
+ if (arg === '--repo') {
427
+ options.repo = args[++index];
428
+ } else if (arg.startsWith('--repo=')) {
429
+ options.repo = arg.slice('--repo='.length);
430
+ } else if (arg === '--branch') {
431
+ options.branch = args[++index];
432
+ } else if (arg.startsWith('--branch=')) {
433
+ options.branch = arg.slice('--branch='.length);
434
+ } else if (arg === '--badge-file') {
435
+ options.badgeFile = args[++index];
436
+ } else if (arg.startsWith('--badge-file=')) {
437
+ options.badgeFile = arg.slice('--badge-file='.length);
438
+ } else if (arg === '--site') {
439
+ options.site = args[++index];
440
+ } else if (arg.startsWith('--site=')) {
441
+ options.site = arg.slice('--site='.length);
442
+ } else {
443
+ throw new Error(`unknown badges option: ${arg}`);
444
+ }
445
+ }
446
+ return options;
447
+ }
448
+
220
449
  function writeOutput(file, output) {
221
450
  const absoluteFile = path.resolve(file);
222
451
  fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
@@ -224,6 +453,19 @@ function writeOutput(file, output) {
224
453
  return absoluteFile;
225
454
  }
226
455
 
456
+ function writeGithubStepSummary({ env, result, options, outputFile }) {
457
+ if (env.GITHUB_ACTIONS !== 'true' || !env.GITHUB_STEP_SUMMARY) return;
458
+
459
+ try {
460
+ fs.appendFileSync(
461
+ env.GITHUB_STEP_SUMMARY,
462
+ `${renderGithubStepSummary(result, { format: options.format, failOn: options.failOn, outputFile })}\n`
463
+ );
464
+ } catch (error) {
465
+ console.error(`awguard: could not write GitHub job summary: ${error.message}`);
466
+ }
467
+ }
468
+
227
469
  function shouldFail(findings, threshold) {
228
470
  if (threshold === 'none') return false;
229
471
  const thresholdRank = severityRank[threshold];
package/src/compare.js CHANGED
@@ -1,5 +1,13 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { classifyScanFile } from './scanner.js';
4
+
5
+ const surfaceLabels = {
6
+ 'github-workflow': 'GitHub Actions workflows',
7
+ 'agent-context': 'Agent context files',
8
+ 'mcp-config': 'MCP configs',
9
+ other: 'Other scanned files'
10
+ };
3
11
 
4
12
  export function loadReport(file) {
5
13
  const absoluteFile = path.resolve(file);
@@ -28,6 +36,10 @@ export function buildComparison(previous, current) {
28
36
  .filter(([fingerprint]) => !currentFindings.has(fingerprint))
29
37
  .map(([, finding]) => finding);
30
38
  const unchangedFindings = [...currentFindings.keys()].filter((fingerprint) => previousFindings.has(fingerprint));
39
+ const addedFiles = [...currentFiles].filter((file) => !previousFiles.has(file)).sort();
40
+ const removedFiles = [...previousFiles].filter((file) => !currentFiles.has(file)).sort();
41
+ const addedSurfaces = groupFilesBySurface(addedFiles, current.root || previous.root);
42
+ const removedSurfaces = groupFilesBySurface(removedFiles, previous.root || current.root);
31
43
 
32
44
  return {
33
45
  summary: {
@@ -36,13 +48,17 @@ export function buildComparison(previous, current) {
36
48
  introducedFindings: introducedFindings.length,
37
49
  resolvedFindings: resolvedFindings.length,
38
50
  unchangedFindings: unchangedFindings.length,
39
- addedFiles: [...currentFiles].filter((file) => !previousFiles.has(file)).length,
40
- removedFiles: [...previousFiles].filter((file) => !currentFiles.has(file)).length
51
+ addedFiles: addedFiles.length,
52
+ removedFiles: removedFiles.length,
53
+ addedSurfaces: addedSurfaces.length,
54
+ removedSurfaces: removedSurfaces.length
41
55
  },
42
56
  introducedFindings,
43
57
  resolvedFindings,
44
- addedFiles: [...currentFiles].filter((file) => !previousFiles.has(file)).sort(),
45
- removedFiles: [...previousFiles].filter((file) => !currentFiles.has(file)).sort()
58
+ addedFiles,
59
+ removedFiles,
60
+ addedSurfaces,
61
+ removedSurfaces
46
62
  };
47
63
  }
48
64
 
@@ -65,6 +81,10 @@ export function renderComparison(previous, current) {
65
81
  appendFindings(lines, comparison.introducedFindings);
66
82
  lines.push('', '## Resolved Findings', '');
67
83
  appendFindings(lines, comparison.resolvedFindings);
84
+ lines.push('', '## Added Agentic Surfaces', '');
85
+ appendSurfaces(lines, comparison.addedSurfaces);
86
+ lines.push('', '## Removed Agentic Surfaces', '');
87
+ appendSurfaces(lines, comparison.removedSurfaces);
68
88
  lines.push('', '## Added Files', '');
69
89
  appendFiles(lines, comparison.addedFiles);
70
90
  lines.push('', '## Removed Files', '');
@@ -73,6 +93,10 @@ export function renderComparison(previous, current) {
73
93
  return lines.join('\n');
74
94
  }
75
95
 
96
+ export function renderComparisonJson(previous, current) {
97
+ return JSON.stringify(buildComparison(previous, current), null, 2);
98
+ }
99
+
76
100
  function appendFindings(lines, findings) {
77
101
  if (findings.length === 0) {
78
102
  lines.push('None.');
@@ -101,10 +125,42 @@ function appendFiles(lines, files) {
101
125
  }
102
126
  }
103
127
 
128
+ function appendSurfaces(lines, surfaces) {
129
+ if (surfaces.length === 0) {
130
+ lines.push('None.');
131
+ return;
132
+ }
133
+
134
+ lines.push('| Surface | Files |');
135
+ lines.push('| --- | ---: |');
136
+ for (const surface of surfaces) {
137
+ lines.push(`| ${escapeMarkdown(surface.label)} | ${surface.files.length} |`);
138
+ }
139
+ }
140
+
104
141
  function mapByFingerprint(findings) {
105
142
  return new Map(findings.map((finding) => [finding.fingerprint || `${finding.ruleId}:${finding.file}:${finding.line}`, finding]));
106
143
  }
107
144
 
145
+ function groupFilesBySurface(files, root = process.cwd()) {
146
+ const groups = new Map();
147
+ for (const file of files) {
148
+ const surface = classifyScanFile(file, root || process.cwd());
149
+ if (!groups.has(surface)) {
150
+ groups.set(surface, {
151
+ surface,
152
+ label: surfaceLabels[surface] || surfaceLabels.other,
153
+ files: []
154
+ });
155
+ }
156
+ groups.get(surface).files.push(file);
157
+ }
158
+
159
+ return [...groups.values()]
160
+ .map((group) => ({ ...group, files: group.files.sort() }))
161
+ .sort((a, b) => a.label.localeCompare(b.label));
162
+ }
163
+
108
164
  function escapeMarkdown(value) {
109
165
  return String(value).replaceAll('|', '\\|');
110
166
  }
package/src/config.js CHANGED
@@ -34,7 +34,8 @@ export function normalizeConfig(rawConfig = {}, source = 'config') {
34
34
  return {
35
35
  rules: normalizeRules(mergedConfig.rules || {}, source),
36
36
  suppressions: normalizeSuppressions(mergedConfig.suppressions || {}, source),
37
- policy: normalizePolicy(mergedConfig.policy || {}, source)
37
+ policy: normalizePolicy(mergedConfig.policy || {}, source),
38
+ scan: normalizeScan(mergedConfig.scan || {}, source)
38
39
  };
39
40
  }
40
41
 
@@ -53,7 +54,8 @@ function mergePresetConfigs(rawConfig, source) {
53
54
  return mergeConfigObjects(merged, {
54
55
  rules: rawConfig.rules || {},
55
56
  suppressions: rawConfig.suppressions || {},
56
- policy: rawConfig.policy || {}
57
+ policy: rawConfig.policy || {},
58
+ scan: rawConfig.scan || {}
57
59
  });
58
60
  }
59
61
 
@@ -70,6 +72,10 @@ function mergeConfigObjects(base, override) {
70
72
  policy: {
71
73
  ...(base.policy || {}),
72
74
  ...(override.policy || {})
75
+ },
76
+ scan: {
77
+ ...(base.scan || {}),
78
+ ...(override.scan || {})
73
79
  }
74
80
  };
75
81
  }
@@ -164,10 +170,33 @@ function normalizePolicy(policy, source) {
164
170
  approvedFiles: normalizeStringArray(policy.approvedFiles || [], `${source} policy.approvedFiles`),
165
171
  approvedMcpServers: normalizeStringArray(policy.approvedMcpServers || [], `${source} policy.approvedMcpServers`),
166
172
  approvedMcpPackages: normalizeStringArray(policy.approvedMcpPackages || [], `${source} policy.approvedMcpPackages`),
173
+ approvedMcpPackageScopes: normalizeStringArray(policy.approvedMcpPackageScopes || [], `${source} policy.approvedMcpPackageScopes`),
167
174
  approvedMcpCommands: normalizeStringArray(policy.approvedMcpCommands || [], `${source} policy.approvedMcpCommands`)
168
175
  };
169
176
  }
170
177
 
178
+ function normalizeScan(scan, source) {
179
+ if (!isObject(scan)) {
180
+ throw new Error(`${source} scan must be an object`);
181
+ }
182
+
183
+ return {
184
+ include: normalizeStringArray(scan.include || [], `${source} scan.include`),
185
+ exclude: normalizeStringArray(scan.exclude || [], `${source} scan.exclude`),
186
+ maxFiles: normalizeOptionalPositiveInteger(scan.maxFiles, `${source} scan.maxFiles`),
187
+ maxFileBytes: normalizeOptionalPositiveInteger(scan.maxFileBytes, `${source} scan.maxFileBytes`)
188
+ };
189
+ }
190
+
191
+ function normalizeOptionalPositiveInteger(value, source) {
192
+ if (value === undefined || value === null || value === '') return undefined;
193
+ const number = Number(value);
194
+ if (!Number.isInteger(number) || number < 1) {
195
+ throw new Error(`${source} must be a positive integer`);
196
+ }
197
+ return number;
198
+ }
199
+
171
200
  function normalizeStringArray(value, source) {
172
201
  if (!Array.isArray(value)) {
173
202
  throw new Error(`${source} must be an array`);
package/src/demo.js ADDED
@@ -0,0 +1,90 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { buildComparison } from './compare.js';
4
+ import { scanWorkflows } from './scanner.js';
5
+ import { calculateScore } from './score.js';
6
+
7
+ export function renderDemoWalkthrough({ packageRoot = getPackageRoot() } = {}) {
8
+ const unsafeRoot = path.join(packageRoot, 'examples', 'lab', 'unsafe');
9
+ const fixedRoot = path.join(packageRoot, 'examples', 'lab', 'fixed');
10
+ const unsafe = scanWorkflows({ root: unsafeRoot });
11
+ const fixed = scanWorkflows({ root: fixedRoot });
12
+ const comparison = buildComparison(toPortableReport(unsafe), toPortableReport(fixed));
13
+ const unsafeScore = calculateScore(unsafe);
14
+ const fixedScore = calculateScore(fixed);
15
+
16
+ const lines = [
17
+ '# Agentic Workflow Guard Demo',
18
+ '',
19
+ 'This offline demo scans the built-in vulnerable lab and its fixed version.',
20
+ '',
21
+ '## Commands',
22
+ '',
23
+ '```bash',
24
+ 'npx awguard@latest examples/lab/unsafe --format inventory',
25
+ 'npx awguard@latest examples/lab/fixed --format inventory',
26
+ 'npx awguard@latest examples/lab/fixed --fail-on high',
27
+ '```',
28
+ '',
29
+ '## Before And After',
30
+ '',
31
+ '| Lab | Scanned files | Findings | Highest | AWI score |',
32
+ '| --- | ---: | ---: | --- | --- |',
33
+ `| Unsafe | ${unsafe.scannedFiles.length} | ${unsafe.findings.length} | ${unsafe.summary.highest} | ${unsafeScore.grade} ${unsafeScore.score}/100 |`,
34
+ `| Fixed | ${fixed.scannedFiles.length} | ${fixed.findings.length} | ${fixed.summary.highest} | ${fixedScore.grade} ${fixedScore.score}/100 |`,
35
+ '',
36
+ '## Resolved Risk',
37
+ '',
38
+ `Resolved findings: **${comparison.summary.resolvedFindings}**`,
39
+ `Remaining findings: **${comparison.summary.currentFindings}**`,
40
+ '',
41
+ '## Unsafe Findings',
42
+ ''
43
+ ];
44
+
45
+ appendFindings(lines, unsafe.findings);
46
+ lines.push(
47
+ '',
48
+ '## Fixed Result',
49
+ '',
50
+ fixed.findings.length === 0
51
+ ? 'The fixed lab is clean for the enabled rules.'
52
+ : `The fixed lab still has ${fixed.findings.length} finding(s).`,
53
+ '',
54
+ 'Lab docs: `examples/lab/README.md`'
55
+ );
56
+
57
+ return lines.join('\n');
58
+ }
59
+
60
+ function appendFindings(lines, findings) {
61
+ if (findings.length === 0) {
62
+ lines.push('None.');
63
+ return;
64
+ }
65
+
66
+ lines.push('| Severity | Rule | Location | Finding |', '| --- | --- | --- | --- |');
67
+ for (const finding of findings) {
68
+ lines.push(
69
+ `| ${escapeMarkdown(finding.severity)} | ${escapeMarkdown(finding.ruleId)} | \`${escapeMarkdown(
70
+ `${finding.file}:${finding.line}`
71
+ )}\` | ${escapeMarkdown(finding.title)} |`
72
+ );
73
+ }
74
+ }
75
+
76
+ function toPortableReport(result) {
77
+ return {
78
+ ...result,
79
+ scannedFiles: result.scannedFiles.map((file) => path.relative(result.root, file) || file)
80
+ };
81
+ }
82
+
83
+ function getPackageRoot() {
84
+ const sourceDir = path.dirname(fileURLToPath(import.meta.url));
85
+ return path.resolve(sourceDir, '..');
86
+ }
87
+
88
+ function escapeMarkdown(value) {
89
+ return String(value).replaceAll('|', '\\|');
90
+ }