awguard 1.5.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 (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/Dockerfile +15 -0
  3. package/README.md +230 -10
  4. package/action.yml +7 -3
  5. package/docs/assets/terminal-demo.svg +19 -0
  6. package/docs/comparison.md +168 -0
  7. package/docs/launch-plan.md +35 -17
  8. package/docs/market-analysis.md +3 -1
  9. package/docs/marketplace-listing.md +59 -0
  10. package/docs/npm-publishing.md +68 -0
  11. package/docs/release-checklist.md +71 -0
  12. package/docs/report-gallery.md +166 -0
  13. package/docs/roadmap.md +41 -7
  14. package/docs/rule-authoring.md +99 -0
  15. package/docs/schemas.md +16 -0
  16. package/docs/setup-recipes.md +199 -0
  17. package/docs/site/index.html +280 -0
  18. package/examples/.gitlab-ci.yml +6 -0
  19. package/examples/.vscode/tasks.json +33 -0
  20. package/examples/README.md +11 -0
  21. package/examples/awguard.config.example.json +14 -0
  22. package/examples/corpus/.cursor/rules/autonomy.mdc +3 -0
  23. package/examples/corpus/.github/prompts/auto-fix.prompt.md +3 -0
  24. package/examples/corpus/.github/workflows/agentic-pr-review.yml +20 -0
  25. package/examples/corpus/.github/workflows/pull-request-target-head.yml +13 -0
  26. package/examples/corpus/.mcp.json +15 -0
  27. package/examples/corpus/AGENTS.md +5 -0
  28. package/examples/corpus/README.md +23 -0
  29. package/examples/dashboard/README.md +55 -0
  30. package/examples/dashboard/index.html +313 -0
  31. package/examples/dashboard/sample-history.json +53 -0
  32. package/examples/lab/README.md +33 -0
  33. package/examples/lab/fixed/.github/workflows/ai-triage.yml +20 -0
  34. package/examples/lab/fixed/.mcp.json +12 -0
  35. package/examples/lab/fixed/AGENTS.md +5 -0
  36. package/examples/lab/unsafe/.github/workflows/ai-triage.yml +16 -0
  37. package/examples/lab/unsafe/.mcp.json +11 -0
  38. package/examples/lab/unsafe/AGENTS.md +4 -0
  39. package/examples/pr-comment-bot.yml +43 -0
  40. package/examples/pre-commit-config.yaml +8 -0
  41. package/examples/pull-request-target.yml +1 -1
  42. package/examples/safe-agent.yml +1 -1
  43. package/examples/unsafe-agent.yml +1 -1
  44. package/examples/vscode-extension/README.md +49 -0
  45. package/examples/vscode-extension/assets/problems-panel.svg +23 -0
  46. package/examples/vscode-extension/package.json +68 -0
  47. package/examples/vscode-extension/src/extension.js +116 -0
  48. package/package.json +3 -1
  49. package/schemas/awguard.badge.schema.json +25 -0
  50. package/schemas/awguard.baseline.schema.json +40 -0
  51. package/schemas/awguard.comparison.schema.json +146 -0
  52. package/schemas/awguard.config.schema.json +167 -0
  53. package/schemas/awguard.inventory.schema.json +124 -0
  54. package/schemas/awguard.report.schema.json +121 -0
  55. package/src/autofix.js +201 -0
  56. package/src/badges.js +63 -0
  57. package/src/baseline.js +77 -0
  58. package/src/cli.js +281 -5
  59. package/src/compare.js +166 -0
  60. package/src/config.js +58 -2
  61. package/src/demo.js +90 -0
  62. package/src/doctor.js +189 -0
  63. package/src/explain.js +147 -0
  64. package/src/graph.js +6 -1
  65. package/src/init.js +84 -0
  66. package/src/inventory.js +11 -0
  67. package/src/migration.js +10 -0
  68. package/src/policy-packs.js +99 -0
  69. package/src/policy-wizard.js +165 -0
  70. package/src/presets.js +2 -1
  71. package/src/remediation.js +92 -1
  72. package/src/reporters.js +92 -5
  73. package/src/scanner.js +295 -10
  74. package/src/score.js +3 -0
  75. package/src/templates.js +132 -0
package/src/cli.js CHANGED
@@ -1,14 +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';
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';
5
17
  import { loadConfig } from './config.js';
18
+ import { buildDoctorReport, renderDoctorReport } from './doctor.js';
19
+ import { renderRuleExplanation } from './explain.js';
20
+ import { renderInitGuide } from './init.js';
21
+ import { renderPolicyPack } from './policy-packs.js';
22
+ import { buildPolicyWizard, renderPolicyWizard } from './policy-wizard.js';
6
23
  import { renderFixDryRun } from './remediation.js';
7
24
  import { scanWorkflows, severityRank } from './scanner.js';
25
+ import { renderTemplates } from './templates.js';
8
26
  import {
9
27
  renderBadge,
10
28
  renderGithubAnnotations,
11
29
  renderGraph,
30
+ renderGithubStepSummary,
12
31
  renderHtml,
13
32
  renderJson,
14
33
  renderMarkdown,
@@ -16,15 +35,35 @@ import {
16
35
  renderSarif,
17
36
  renderScore,
18
37
  renderSurfaceInventory,
38
+ renderSurfaceInventoryJson,
19
39
  renderText
20
40
  } from './reporters.js';
21
41
 
22
42
  const HELP = `Agentic Workflow Guard
23
43
 
24
44
  Usage:
25
- awguard [path] [--config file] [--preset name] [--format text|json|markdown|github|sarif|graph|html|migration|score|badge|inventory] [--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]
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]
55
+ awguard --compare previous.json current.json
26
56
 
27
57
  Examples:
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
28
67
  awguard .
29
68
  awguard .mcp.json
30
69
  awguard . --config awguard.config.json
@@ -33,16 +72,92 @@ Examples:
33
72
  awguard . --format html --output awguard-report.html
34
73
  awguard . --format migration --output awguard-migration.md
35
74
  awguard . --format inventory
75
+ awguard . --format inventory-json --output awguard-inventory.json
36
76
  awguard . --format score
37
77
  awguard . --format badge --output awguard-badge.json
38
78
  awguard . --fix-dry-run
79
+ awguard . --fix
39
80
  awguard . --format sarif --output awguard.sarif --fail-on none
40
81
  awguard . --write-baseline awguard.baseline.json
41
82
  awguard . --baseline awguard.baseline.json --fail-on high
83
+ awguard --compare old-awguard.json new-awguard.json
42
84
  awguard . --format github --fail-on medium
43
85
  `;
44
86
 
45
87
  export async function runCli(args, env = process.env) {
88
+ if (args[0] === 'init') {
89
+ console.log(renderInitGuide());
90
+ return;
91
+ }
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
+
46
161
  const options = parseArgs(args, env);
47
162
 
48
163
  if (options.help) {
@@ -50,6 +165,19 @@ export async function runCli(args, env = process.env) {
50
165
  return;
51
166
  }
52
167
 
168
+ if (options.compare.length > 0) {
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);
172
+ if (options.output) {
173
+ const outputFile = writeOutput(options.output, output);
174
+ console.error(`Wrote ${outputFile}`);
175
+ } else {
176
+ console.log(output);
177
+ }
178
+ return;
179
+ }
180
+
53
181
  const { config } = loadConfig({ configPath: options.config, root: options.path, presets: options.presets });
54
182
  let result = scanWorkflows({ root: options.path, config });
55
183
 
@@ -62,15 +190,24 @@ export async function runCli(args, env = process.env) {
62
190
  console.error(`Wrote baseline ${baselineFile}`);
63
191
  }
64
192
 
65
- 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);
66
200
 
201
+ let outputFile = '';
67
202
  if (options.output) {
68
- const outputFile = writeOutput(options.output, output);
203
+ outputFile = writeOutput(options.output, output);
69
204
  console.error(`Wrote ${outputFile}`);
70
205
  } else if (output.trim().length > 0) {
71
206
  console.log(output);
72
207
  }
73
208
 
209
+ writeGithubStepSummary({ env, result, options, outputFile });
210
+
74
211
  const findingsToFailOn = result.findings.filter((finding) => finding.baselineState !== 'known');
75
212
  if (shouldFail(findingsToFailOn, options.failOn)) {
76
213
  process.exitCode = 1;
@@ -87,8 +224,10 @@ export function parseArgs(args, env = {}) {
87
224
  baseline: readInput(env, 'baseline') || '',
88
225
  writeBaseline: readInput(env, 'write_baseline') || readInput(env, 'write-baseline') || '',
89
226
  config: readInput(env, 'config') || '',
227
+ compare: [],
90
228
  presets: splitList(readInput(env, 'preset') || readInput(env, 'presets') || ''),
91
229
  fixDryRun: readBoolInput(env, 'fix_dry_run') || readBoolInput(env, 'fix-dry-run'),
230
+ fix: readBoolInput(env, 'fix'),
92
231
  help: false
93
232
  };
94
233
 
@@ -121,12 +260,18 @@ export function parseArgs(args, env = {}) {
121
260
  options.config = args[++index];
122
261
  } else if (arg.startsWith('--config=')) {
123
262
  options.config = arg.slice('--config='.length);
263
+ } else if (arg === '--compare') {
264
+ options.compare = [args[++index], args[++index]].filter(Boolean);
265
+ } else if (arg.startsWith('--compare=')) {
266
+ options.compare = arg.slice('--compare='.length).split(',').map((item) => item.trim()).filter(Boolean);
124
267
  } else if (arg === '--preset') {
125
268
  options.presets.push(...splitList(args[++index]));
126
269
  } else if (arg.startsWith('--preset=')) {
127
270
  options.presets.push(...splitList(arg.slice('--preset='.length)));
128
271
  } else if (arg === '--fix-dry-run') {
129
272
  options.fixDryRun = true;
273
+ } else if (arg === '--fix') {
274
+ options.fix = true;
130
275
  } else if (!arg.startsWith('-')) {
131
276
  options.path = arg;
132
277
  } else {
@@ -145,9 +290,13 @@ export function parseArgs(args, env = {}) {
145
290
  'migration',
146
291
  'score',
147
292
  'badge',
148
- 'inventory'
293
+ 'inventory',
294
+ 'inventory-json'
149
295
  ]);
150
296
  validateEnum('fail-on', options.failOn, ['none', 'low', 'medium', 'high', 'critical']);
297
+ if (options.compare.length !== 0 && options.compare.length !== 2) {
298
+ throw new Error('--compare requires two awguard --format json report files');
299
+ }
151
300
 
152
301
  return options;
153
302
  }
@@ -167,6 +316,7 @@ function render(result, format) {
167
316
  if (format === 'score') return renderScore(result);
168
317
  if (format === 'badge') return renderBadge(result);
169
318
  if (format === 'inventory') return renderSurfaceInventory(result);
319
+ if (format === 'inventory-json') return renderSurfaceInventoryJson(result);
170
320
  if (format === 'github') return renderGithubAnnotations(result);
171
321
  return renderText(result);
172
322
  }
@@ -183,6 +333,119 @@ function readBoolInput(env, name) {
183
333
  return value === 'true' || value === '1' || value === 'yes';
184
334
  }
185
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
+
186
449
  function writeOutput(file, output) {
187
450
  const absoluteFile = path.resolve(file);
188
451
  fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
@@ -190,6 +453,19 @@ function writeOutput(file, output) {
190
453
  return absoluteFile;
191
454
  }
192
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
+
193
469
  function shouldFail(findings, threshold) {
194
470
  if (threshold === 'none') return false;
195
471
  const thresholdRank = severityRank[threshold];
package/src/compare.js ADDED
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
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
+ };
11
+
12
+ export function loadReport(file) {
13
+ const absoluteFile = path.resolve(file);
14
+ if (!fs.existsSync(absoluteFile)) {
15
+ throw new Error(`report file does not exist: ${absoluteFile}`);
16
+ }
17
+
18
+ const report = JSON.parse(fs.readFileSync(absoluteFile, 'utf8'));
19
+ if (!Array.isArray(report.findings) || !Array.isArray(report.scannedFiles)) {
20
+ throw new Error(`report file must be awguard --format json output: ${absoluteFile}`);
21
+ }
22
+
23
+ return report;
24
+ }
25
+
26
+ export function buildComparison(previous, current) {
27
+ const previousFindings = mapByFingerprint(previous.findings);
28
+ const currentFindings = mapByFingerprint(current.findings);
29
+ const previousFiles = new Set(previous.scannedFiles || []);
30
+ const currentFiles = new Set(current.scannedFiles || []);
31
+
32
+ const introducedFindings = [...currentFindings.entries()]
33
+ .filter(([fingerprint]) => !previousFindings.has(fingerprint))
34
+ .map(([, finding]) => finding);
35
+ const resolvedFindings = [...previousFindings.entries()]
36
+ .filter(([fingerprint]) => !currentFindings.has(fingerprint))
37
+ .map(([, finding]) => finding);
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);
43
+
44
+ return {
45
+ summary: {
46
+ previousFindings: previous.findings.length,
47
+ currentFindings: current.findings.length,
48
+ introducedFindings: introducedFindings.length,
49
+ resolvedFindings: resolvedFindings.length,
50
+ unchangedFindings: unchangedFindings.length,
51
+ addedFiles: addedFiles.length,
52
+ removedFiles: removedFiles.length,
53
+ addedSurfaces: addedSurfaces.length,
54
+ removedSurfaces: removedSurfaces.length
55
+ },
56
+ introducedFindings,
57
+ resolvedFindings,
58
+ addedFiles,
59
+ removedFiles,
60
+ addedSurfaces,
61
+ removedSurfaces
62
+ };
63
+ }
64
+
65
+ export function renderComparison(previous, current) {
66
+ const comparison = buildComparison(previous, current);
67
+ const lines = [
68
+ '# Agentic Workflow Guard Comparison',
69
+ '',
70
+ `Previous findings: **${comparison.summary.previousFindings}**`,
71
+ `Current findings: **${comparison.summary.currentFindings}**`,
72
+ `Introduced findings: **${comparison.summary.introducedFindings}**`,
73
+ `Resolved findings: **${comparison.summary.resolvedFindings}**`,
74
+ `Unchanged findings: **${comparison.summary.unchangedFindings}**`,
75
+ `Added scanned files: **${comparison.summary.addedFiles}**`,
76
+ `Removed scanned files: **${comparison.summary.removedFiles}**`,
77
+ ''
78
+ ];
79
+
80
+ lines.push('## Introduced Findings', '');
81
+ appendFindings(lines, comparison.introducedFindings);
82
+ lines.push('', '## Resolved Findings', '');
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);
88
+ lines.push('', '## Added Files', '');
89
+ appendFiles(lines, comparison.addedFiles);
90
+ lines.push('', '## Removed Files', '');
91
+ appendFiles(lines, comparison.removedFiles);
92
+
93
+ return lines.join('\n');
94
+ }
95
+
96
+ export function renderComparisonJson(previous, current) {
97
+ return JSON.stringify(buildComparison(previous, current), null, 2);
98
+ }
99
+
100
+ function appendFindings(lines, findings) {
101
+ if (findings.length === 0) {
102
+ lines.push('None.');
103
+ return;
104
+ }
105
+
106
+ lines.push('| Severity | Rule | Location | Finding |');
107
+ lines.push('| --- | --- | --- | --- |');
108
+ for (const finding of findings) {
109
+ lines.push(
110
+ `| ${escapeMarkdown(finding.severity)} | ${escapeMarkdown(finding.ruleId)} | ${escapeMarkdown(
111
+ `${finding.file}:${finding.line}`
112
+ )} | ${escapeMarkdown(finding.title)} |`
113
+ );
114
+ }
115
+ }
116
+
117
+ function appendFiles(lines, files) {
118
+ if (files.length === 0) {
119
+ lines.push('None.');
120
+ return;
121
+ }
122
+
123
+ for (const file of files) {
124
+ lines.push(`- \`${file.replaceAll('`', '\\`')}\``);
125
+ }
126
+ }
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
+
141
+ function mapByFingerprint(findings) {
142
+ return new Map(findings.map((finding) => [finding.fingerprint || `${finding.ruleId}:${finding.file}:${finding.line}`, finding]));
143
+ }
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
+
164
+ function escapeMarkdown(value) {
165
+ return String(value).replaceAll('|', '\\|');
166
+ }
package/src/config.js CHANGED
@@ -33,7 +33,9 @@ export function normalizeConfig(rawConfig = {}, source = 'config') {
33
33
 
34
34
  return {
35
35
  rules: normalizeRules(mergedConfig.rules || {}, source),
36
- suppressions: normalizeSuppressions(mergedConfig.suppressions || {}, source)
36
+ suppressions: normalizeSuppressions(mergedConfig.suppressions || {}, source),
37
+ policy: normalizePolicy(mergedConfig.policy || {}, source),
38
+ scan: normalizeScan(mergedConfig.scan || {}, source)
37
39
  };
38
40
  }
39
41
 
@@ -51,7 +53,9 @@ function mergePresetConfigs(rawConfig, source) {
51
53
 
52
54
  return mergeConfigObjects(merged, {
53
55
  rules: rawConfig.rules || {},
54
- suppressions: rawConfig.suppressions || {}
56
+ suppressions: rawConfig.suppressions || {},
57
+ policy: rawConfig.policy || {},
58
+ scan: rawConfig.scan || {}
55
59
  });
56
60
  }
57
61
 
@@ -64,6 +68,14 @@ function mergeConfigObjects(base, override) {
64
68
  suppressions: {
65
69
  ...(base.suppressions || {}),
66
70
  ...(override.suppressions || {})
71
+ },
72
+ policy: {
73
+ ...(base.policy || {}),
74
+ ...(override.policy || {})
75
+ },
76
+ scan: {
77
+ ...(base.scan || {}),
78
+ ...(override.scan || {})
67
79
  }
68
80
  };
69
81
  }
@@ -149,6 +161,50 @@ function normalizeSuppressions(suppressions, source) {
149
161
  };
150
162
  }
151
163
 
164
+ function normalizePolicy(policy, source) {
165
+ if (!isObject(policy)) {
166
+ throw new Error(`${source} policy must be an object`);
167
+ }
168
+
169
+ return {
170
+ approvedFiles: normalizeStringArray(policy.approvedFiles || [], `${source} policy.approvedFiles`),
171
+ approvedMcpServers: normalizeStringArray(policy.approvedMcpServers || [], `${source} policy.approvedMcpServers`),
172
+ approvedMcpPackages: normalizeStringArray(policy.approvedMcpPackages || [], `${source} policy.approvedMcpPackages`),
173
+ approvedMcpPackageScopes: normalizeStringArray(policy.approvedMcpPackageScopes || [], `${source} policy.approvedMcpPackageScopes`),
174
+ approvedMcpCommands: normalizeStringArray(policy.approvedMcpCommands || [], `${source} policy.approvedMcpCommands`)
175
+ };
176
+ }
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
+
200
+ function normalizeStringArray(value, source) {
201
+ if (!Array.isArray(value)) {
202
+ throw new Error(`${source} must be an array`);
203
+ }
204
+
205
+ return value.map((item) => String(item));
206
+ }
207
+
152
208
  function ensureKnownRule(ruleId, source) {
153
209
  if (!ruleCatalog[ruleId]) {
154
210
  throw new Error(`${source} references unknown rule id: ${ruleId}`);