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.
- package/CHANGELOG.md +32 -0
- package/Dockerfile +8 -1
- package/README.md +176 -12
- package/action.yml +5 -1
- package/docs/comparison.md +161 -16
- package/docs/launch-plan.md +12 -2
- package/docs/marketplace-listing.md +19 -0
- package/docs/npm-publishing.md +68 -0
- package/docs/release-checklist.md +71 -0
- package/docs/report-gallery.md +166 -0
- package/docs/roadmap.md +32 -2
- package/docs/rule-authoring.md +99 -0
- package/docs/schemas.md +16 -0
- package/docs/setup-recipes.md +199 -0
- package/docs/site/index.html +29 -0
- package/examples/.vscode/tasks.json +17 -1
- package/examples/README.md +7 -0
- package/examples/awguard.config.example.json +8 -0
- package/examples/corpus/.cursor/rules/autonomy.mdc +3 -0
- package/examples/corpus/.github/prompts/auto-fix.prompt.md +3 -0
- package/examples/corpus/.github/workflows/agentic-pr-review.yml +20 -0
- package/examples/corpus/.github/workflows/pull-request-target-head.yml +13 -0
- package/examples/corpus/.mcp.json +15 -0
- package/examples/corpus/AGENTS.md +5 -0
- package/examples/corpus/README.md +23 -0
- package/examples/dashboard/README.md +55 -0
- package/examples/dashboard/index.html +313 -0
- package/examples/dashboard/sample-history.json +53 -0
- package/examples/lab/README.md +6 -0
- package/examples/pr-comment-bot.yml +43 -0
- package/examples/pull-request-target.yml +1 -1
- package/examples/safe-agent.yml +1 -1
- package/examples/unsafe-agent.yml +1 -1
- package/examples/vscode-extension/README.md +49 -0
- package/examples/vscode-extension/assets/problems-panel.svg +23 -0
- package/examples/vscode-extension/package.json +68 -0
- package/examples/vscode-extension/src/extension.js +116 -0
- package/package.json +2 -1
- package/schemas/awguard.badge.schema.json +25 -0
- package/schemas/awguard.baseline.schema.json +40 -0
- package/schemas/awguard.comparison.schema.json +146 -0
- package/schemas/awguard.config.schema.json +167 -0
- package/schemas/awguard.inventory.schema.json +124 -0
- package/schemas/awguard.report.schema.json +121 -0
- package/src/autofix.js +201 -0
- package/src/badges.js +63 -0
- package/src/baseline.js +77 -0
- package/src/cli.js +248 -6
- package/src/compare.js +60 -4
- package/src/config.js +31 -2
- package/src/demo.js +90 -0
- package/src/doctor.js +189 -0
- package/src/explain.js +147 -0
- package/src/init.js +4 -1
- package/src/policy-packs.js +99 -0
- package/src/policy-wizard.js +165 -0
- package/src/remediation.js +73 -1
- package/src/reporters.js +86 -3
- package/src/scanner.js +204 -5
- package/src/templates.js +132 -0
package/src/doctor.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { scanWorkflows } from './scanner.js';
|
|
7
|
+
|
|
8
|
+
const schemaPath = 'schemas/awguard.config.schema.json';
|
|
9
|
+
|
|
10
|
+
export function buildDoctorReport({ root = '.', configPath = '', presets = [], env = process.env } = {}) {
|
|
11
|
+
const checks = [];
|
|
12
|
+
const packageInfo = readPackageInfo();
|
|
13
|
+
const packageRoot = getPackageRoot();
|
|
14
|
+
|
|
15
|
+
addCheck(
|
|
16
|
+
checks,
|
|
17
|
+
nodeMajor(process.version) >= 20 ? 'ok' : 'fail',
|
|
18
|
+
'Node.js runtime',
|
|
19
|
+
`${process.version} detected; AWGuard requires Node.js 20 or newer.`,
|
|
20
|
+
'Install a current Node.js release before running AWGuard.'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
addCheck(
|
|
24
|
+
checks,
|
|
25
|
+
packageInfo.version ? 'ok' : 'warn',
|
|
26
|
+
'AWGuard package metadata',
|
|
27
|
+
packageInfo.version ? `awguard ${packageInfo.version}` : 'Could not read package.json version.',
|
|
28
|
+
'Reinstall the package if this command is running from a broken checkout.'
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const absoluteRoot = path.resolve(root);
|
|
32
|
+
if (!fs.existsSync(absoluteRoot)) {
|
|
33
|
+
addCheck(
|
|
34
|
+
checks,
|
|
35
|
+
'fail',
|
|
36
|
+
'Scan target',
|
|
37
|
+
`Target does not exist: ${absoluteRoot}`,
|
|
38
|
+
'Pass a repository directory, workflow file, agent instruction file, or MCP config file.'
|
|
39
|
+
);
|
|
40
|
+
return finish(checks);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const targetStat = fs.statSync(absoluteRoot);
|
|
44
|
+
addCheck(
|
|
45
|
+
checks,
|
|
46
|
+
targetStat.isDirectory() || targetStat.isFile() ? 'ok' : 'fail',
|
|
47
|
+
'Scan target',
|
|
48
|
+
`${absoluteRoot} is a ${targetStat.isDirectory() ? 'directory' : targetStat.isFile() ? 'file' : 'special file'}.`,
|
|
49
|
+
'Pass a regular file or directory.'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let configResult;
|
|
53
|
+
try {
|
|
54
|
+
configResult = loadConfig({ configPath, root: absoluteRoot, presets });
|
|
55
|
+
addCheck(
|
|
56
|
+
checks,
|
|
57
|
+
'ok',
|
|
58
|
+
'Configuration',
|
|
59
|
+
configResult.path
|
|
60
|
+
? `Loaded ${path.relative(process.cwd(), configResult.path) || configResult.path}.`
|
|
61
|
+
: 'No config file found; using defaults and CLI presets.',
|
|
62
|
+
`Add "$schema": "https://raw.githubusercontent.com/Mughal-Baig/agentic-workflow-guard/main/${schemaPath}" to awguard.config.json for editor validation.`
|
|
63
|
+
);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
addCheck(checks, 'fail', 'Configuration', error.message, 'Fix the config file or run without --config.');
|
|
66
|
+
return finish(checks);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
addCheck(
|
|
70
|
+
checks,
|
|
71
|
+
fs.existsSync(path.join(packageRoot, schemaPath)) ? 'ok' : 'warn',
|
|
72
|
+
'Config schema',
|
|
73
|
+
`Schema path: ${schemaPath}`,
|
|
74
|
+
'Publish package files with the schemas directory included.'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = scanWorkflows({ root: absoluteRoot, config: configResult.config });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
addCheck(checks, 'fail', 'Scanner smoke test', error.message, 'Check file permissions and scan target contents.');
|
|
82
|
+
return finish(checks);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (result.scannedFiles.length === 0) {
|
|
86
|
+
addCheck(
|
|
87
|
+
checks,
|
|
88
|
+
'warn',
|
|
89
|
+
'Scanner smoke test',
|
|
90
|
+
'No GitHub Actions workflow, agent instruction, or MCP config files were found.',
|
|
91
|
+
'Run AWGuard again after adding agent workflows, AGENTS.md-style files, or MCP configs.'
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
const findingText =
|
|
95
|
+
result.findings.length === 0
|
|
96
|
+
? 'no findings'
|
|
97
|
+
: `${result.findings.length} finding(s), highest severity ${result.summary.highest}`;
|
|
98
|
+
addCheck(
|
|
99
|
+
checks,
|
|
100
|
+
result.findings.length === 0 ? 'ok' : 'warn',
|
|
101
|
+
'Scanner smoke test',
|
|
102
|
+
`Scanned ${result.scannedFiles.length} file(s); ${findingText}.`,
|
|
103
|
+
'Use --format score, --format inventory, or --format sarif for CI-ready reports.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
108
|
+
addCheck(
|
|
109
|
+
checks,
|
|
110
|
+
env.GITHUB_STEP_SUMMARY ? 'ok' : 'warn',
|
|
111
|
+
'GitHub Actions summary',
|
|
112
|
+
env.GITHUB_STEP_SUMMARY
|
|
113
|
+
? 'GITHUB_STEP_SUMMARY is available for job summary output.'
|
|
114
|
+
: 'GITHUB_STEP_SUMMARY is not available in this environment.',
|
|
115
|
+
'Run inside a modern GitHub Actions job to receive the Markdown job summary.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return finish(checks);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renderDoctorReport(report) {
|
|
123
|
+
const lines = [
|
|
124
|
+
'# Agentic Workflow Guard Doctor',
|
|
125
|
+
'',
|
|
126
|
+
`Status: **${report.status.toUpperCase()}**`,
|
|
127
|
+
'',
|
|
128
|
+
'| Check | Status | Detail |',
|
|
129
|
+
'| --- | --- | --- |'
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
for (const check of report.checks) {
|
|
133
|
+
lines.push(`| ${escapeMarkdown(check.title)} | ${statusLabel(check.status)} | ${escapeMarkdown(check.detail)} |`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const nextSteps = report.checks.filter((check) => check.status !== 'ok' && check.nextStep);
|
|
137
|
+
if (nextSteps.length > 0) {
|
|
138
|
+
lines.push('', '## Next Steps', '');
|
|
139
|
+
for (const check of nextSteps) {
|
|
140
|
+
lines.push(`- ${check.title}: ${check.nextStep}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function addCheck(checks, status, title, detail, nextStep = '') {
|
|
148
|
+
checks.push({ status, title, detail, nextStep });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finish(checks) {
|
|
152
|
+
return {
|
|
153
|
+
status: checks.some((check) => check.status === 'fail')
|
|
154
|
+
? 'fail'
|
|
155
|
+
: checks.some((check) => check.status === 'warn')
|
|
156
|
+
? 'warn'
|
|
157
|
+
: 'ok',
|
|
158
|
+
checks
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nodeMajor(version) {
|
|
163
|
+
const match = String(version).match(/^v?(\d+)/);
|
|
164
|
+
return match ? Number(match[1]) : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readPackageInfo() {
|
|
168
|
+
const packageFile = path.join(getPackageRoot(), 'package.json');
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(fs.readFileSync(packageFile, 'utf8'));
|
|
171
|
+
} catch {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getPackageRoot() {
|
|
177
|
+
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
178
|
+
return path.resolve(sourceDir, '..');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function statusLabel(status) {
|
|
182
|
+
if (status === 'ok') return 'OK';
|
|
183
|
+
if (status === 'warn') return 'WARN';
|
|
184
|
+
return 'FAIL';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeMarkdown(value) {
|
|
188
|
+
return String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
189
|
+
}
|
package/src/explain.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ruleCatalog } from './scanner.js';
|
|
2
|
+
|
|
3
|
+
const ruleDetails = {
|
|
4
|
+
AWG001: {
|
|
5
|
+
detects: 'User-controlled GitHub issue, pull request, comment, branch, or event text reaching an AI prompt.',
|
|
6
|
+
why: 'The model may treat attacker-supplied text as instructions and use privileged tools or repository context.',
|
|
7
|
+
safePattern: 'Delimit untrusted text, keep the job read-only, and require human review before any write action.'
|
|
8
|
+
},
|
|
9
|
+
AWG002: {
|
|
10
|
+
detects: 'Untrusted GitHub context interpolated directly into shell scripts.',
|
|
11
|
+
why: 'Shell interpolation can turn event text into command execution or argument injection.',
|
|
12
|
+
safePattern: 'Move values into environment variables and reference them with shell quoting.'
|
|
13
|
+
},
|
|
14
|
+
AWG003: {
|
|
15
|
+
detects: 'pull_request_target workflows that check out pull request head code.',
|
|
16
|
+
why: 'Untrusted fork code can run with base-repository privileges.',
|
|
17
|
+
safePattern: 'Use pull_request for untrusted code or keep pull_request_target limited to metadata-only work.'
|
|
18
|
+
},
|
|
19
|
+
AWG004: {
|
|
20
|
+
detects: 'Agent jobs with broad write-capable GitHub token permissions.',
|
|
21
|
+
why: 'Prompt injection becomes much more damaging when the agent can write repository state.',
|
|
22
|
+
safePattern: 'Use contents: read by default and isolate write actions behind a reviewed apply job.'
|
|
23
|
+
},
|
|
24
|
+
AWG005: {
|
|
25
|
+
detects: 'Secrets exposed to workflows driven by untrusted agent input.',
|
|
26
|
+
why: 'An attacker can steer the agent or scripts into revealing credentials.',
|
|
27
|
+
safePattern: 'Keep secrets out of untrusted event workflows and use a separate approved privileged workflow.'
|
|
28
|
+
},
|
|
29
|
+
AWG006: {
|
|
30
|
+
detects: 'Agent command flags that skip approvals or permission prompts.',
|
|
31
|
+
why: 'Autonomous execution removes the human gate that normally stops unsafe tool use.',
|
|
32
|
+
safePattern: 'Require confirmation for file writes, command execution, and repository changes.'
|
|
33
|
+
},
|
|
34
|
+
AWG007: {
|
|
35
|
+
detects: 'Model or agent output flowing into eval, shell, or pipe-to-shell execution.',
|
|
36
|
+
why: 'Model output is data, not trusted code.',
|
|
37
|
+
safePattern: 'Validate structured output with a narrow parser before using it.'
|
|
38
|
+
},
|
|
39
|
+
AWG008: {
|
|
40
|
+
detects: 'Agent workflows without explicit permissions.',
|
|
41
|
+
why: 'Default permissions are easy to misunderstand and can drift as repository settings change.',
|
|
42
|
+
safePattern: 'Declare the minimum required permissions in every agent workflow.'
|
|
43
|
+
},
|
|
44
|
+
AWG009: {
|
|
45
|
+
detects: 'workflow_run jobs that consume artifacts before scripts execute.',
|
|
46
|
+
why: 'Artifacts can carry untrusted content from an earlier workflow into a more privileged job.',
|
|
47
|
+
safePattern: 'Verify artifact provenance, names, checksums, and expected schema before use.'
|
|
48
|
+
},
|
|
49
|
+
AWG010: {
|
|
50
|
+
detects: 'Third-party actions that are not pinned to commit SHAs in sensitive agent workflows.',
|
|
51
|
+
why: 'Mutable tags can change behavior after review.',
|
|
52
|
+
safePattern: 'Pin third-party actions to reviewed commit SHAs.'
|
|
53
|
+
},
|
|
54
|
+
AWG011: {
|
|
55
|
+
detects: 'Suppression comments that are malformed, unjustified, or disallowed by config.',
|
|
56
|
+
why: 'Weak suppressions can hide real agentic workflow risk.',
|
|
57
|
+
safePattern: 'Use narrow rule IDs and a clear reviewed false-positive reason.'
|
|
58
|
+
},
|
|
59
|
+
AWG012: {
|
|
60
|
+
detects: 'Persistent agent instructions that weaken approval, permission, or secret boundaries.',
|
|
61
|
+
why: 'Instruction files are durable context that can affect future agent runs.',
|
|
62
|
+
safePattern: 'Keep repository instructions conservative and explicit about human review.'
|
|
63
|
+
},
|
|
64
|
+
AWG013: {
|
|
65
|
+
detects: 'MCP configs that start mutable packages, unpinned containers, or shell wrappers.',
|
|
66
|
+
why: 'MCP servers expand agent tool authority before runtime scanners can inspect behavior.',
|
|
67
|
+
safePattern: 'Pin packages/images and avoid shell wrappers for project-scoped MCP servers.'
|
|
68
|
+
},
|
|
69
|
+
AWG014: {
|
|
70
|
+
detects: 'MCP configs that hardcode credentials or authorization material.',
|
|
71
|
+
why: 'Committed secrets can be used by anyone with repository access and may be exposed by agents.',
|
|
72
|
+
safePattern: 'Move credentials into prompts, environment variables, or a secret manager.'
|
|
73
|
+
},
|
|
74
|
+
AWG015: {
|
|
75
|
+
detects: 'Agentic files, MCP servers, packages, or commands outside configured policy allowlists.',
|
|
76
|
+
why: 'New agent surfaces should be visible in review before they gain trust.',
|
|
77
|
+
safePattern: 'Approve only reviewed files, servers, packages, and commands.'
|
|
78
|
+
},
|
|
79
|
+
AWG016: {
|
|
80
|
+
detects: 'actions/checkout credentials persisting in elevated agent workflows.',
|
|
81
|
+
why: 'Persisted checkout credentials can give later agent or shell steps repository write access.',
|
|
82
|
+
safePattern: 'Use persist-credentials: false and keep writeback in a separate reviewed job.'
|
|
83
|
+
},
|
|
84
|
+
AWG017: {
|
|
85
|
+
detects: 'Agent jobs with write permissions that commit, tag, publish, or push without a reviewable boundary.',
|
|
86
|
+
why: 'Autonomous writeback can change protected repository state before a maintainer reviews the result.',
|
|
87
|
+
safePattern: 'Push to an isolated branch, open a draft pull request, or upload artifacts for a human apply step.'
|
|
88
|
+
},
|
|
89
|
+
AWG018: {
|
|
90
|
+
detects: 'Untrusted GitHub event text flowing into MCP tool arguments or environment variables.',
|
|
91
|
+
why: 'MCP tools can bridge prompt input into external systems, so injected text can become tool instructions.',
|
|
92
|
+
safePattern: 'Treat event text as untrusted data, sanitize it, and require review before passing it to MCP tools.'
|
|
93
|
+
},
|
|
94
|
+
AWG019: {
|
|
95
|
+
detects: 'MCP package specs outside configured trusted package scopes.',
|
|
96
|
+
why: 'Project-scoped MCP packages expand agent capability, so publisher reputation should be reviewed before trust is granted.',
|
|
97
|
+
safePattern: 'Keep trusted package scopes narrow, pin approved packages, and document the review in policy.'
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function renderRuleExplanation(ruleId = '') {
|
|
102
|
+
const normalizedRuleId = String(ruleId || '').toUpperCase();
|
|
103
|
+
if (!normalizedRuleId) return renderRuleIndex();
|
|
104
|
+
|
|
105
|
+
const rule = ruleCatalog[normalizedRuleId];
|
|
106
|
+
if (!rule) {
|
|
107
|
+
throw new Error(`unknown rule id: ${normalizedRuleId}. Use awguard explain to list available rules.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const details = ruleDetails[normalizedRuleId] || {};
|
|
111
|
+
return [
|
|
112
|
+
`# ${normalizedRuleId}: ${rule.title}`,
|
|
113
|
+
'',
|
|
114
|
+
`Severity: **${rule.severity}**`,
|
|
115
|
+
'',
|
|
116
|
+
`Remediation code: \`${rule.remediationCode}\``,
|
|
117
|
+
'',
|
|
118
|
+
`Detects: ${details.detects || rule.title}`,
|
|
119
|
+
'',
|
|
120
|
+
`Why it matters: ${details.why || 'This pattern can increase agentic workflow risk.'}`,
|
|
121
|
+
'',
|
|
122
|
+
`Safe pattern: ${details.safePattern || rule.suggestion}`,
|
|
123
|
+
'',
|
|
124
|
+
`Suggested fix: ${rule.suggestion}`,
|
|
125
|
+
'',
|
|
126
|
+
'Useful commands:',
|
|
127
|
+
'',
|
|
128
|
+
'```bash',
|
|
129
|
+
'npx awguard@latest . --format inventory',
|
|
130
|
+
'npx awguard@latest . --fix-dry-run',
|
|
131
|
+
'npx awguard@latest . --format sarif --output awguard.sarif --fail-on none',
|
|
132
|
+
'```'
|
|
133
|
+
].join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderRuleIndex() {
|
|
137
|
+
const lines = ['# Agentic Workflow Guard Rules', '', '| Rule | Severity | Remediation Code | Title |', '| --- | --- | --- | --- |'];
|
|
138
|
+
for (const [ruleId, rule] of Object.entries(ruleCatalog)) {
|
|
139
|
+
lines.push(`| ${ruleId} | ${rule.severity} | \`${rule.remediationCode}\` | ${escapeMarkdown(rule.title)} |`);
|
|
140
|
+
}
|
|
141
|
+
lines.push('', 'Run `awguard explain AWG001` for details about one rule.');
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function escapeMarkdown(value) {
|
|
146
|
+
return String(value).replaceAll('|', '\\|');
|
|
147
|
+
}
|
package/src/init.js
CHANGED
|
@@ -21,7 +21,7 @@ export function renderInitGuide({ actionRef = 'v0' } = {}) {
|
|
|
21
21
|
' scan:',
|
|
22
22
|
' runs-on: ubuntu-latest',
|
|
23
23
|
' steps:',
|
|
24
|
-
' - uses: actions/checkout@
|
|
24
|
+
' - uses: actions/checkout@v6',
|
|
25
25
|
` - uses: Mughal-Baig/agentic-workflow-guard@${actionRef}`,
|
|
26
26
|
' with:',
|
|
27
27
|
' preset: strict',
|
|
@@ -40,11 +40,14 @@ export function renderInitGuide({ actionRef = 'v0' } = {}) {
|
|
|
40
40
|
'```json',
|
|
41
41
|
JSON.stringify(
|
|
42
42
|
{
|
|
43
|
+
$schema:
|
|
44
|
+
'https://raw.githubusercontent.com/Mughal-Baig/agentic-workflow-guard/main/schemas/awguard.config.schema.json',
|
|
43
45
|
extends: ['strict'],
|
|
44
46
|
policy: {
|
|
45
47
|
approvedFiles: ['AGENTS.md', '.github/workflows/*'],
|
|
46
48
|
approvedMcpServers: [],
|
|
47
49
|
approvedMcpPackages: [],
|
|
50
|
+
approvedMcpPackageScopes: ['@modelcontextprotocol/'],
|
|
48
51
|
approvedMcpCommands: ['npx', 'node', 'uvx', 'docker']
|
|
49
52
|
},
|
|
50
53
|
suppressions: {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const policyPackNames = ['oss', 'strict', 'enterprise'];
|
|
2
|
+
|
|
3
|
+
export function renderPolicyPack(name = 'oss') {
|
|
4
|
+
const normalized = String(name || 'oss').toLowerCase();
|
|
5
|
+
if (normalized === 'list') return renderPolicyPackList();
|
|
6
|
+
const pack = policyPacks[normalized];
|
|
7
|
+
if (!pack) {
|
|
8
|
+
throw new Error(`unknown policy pack: ${name}. Available policy packs: ${policyPackNames.join(', ')}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return [
|
|
12
|
+
`# AWGuard Policy Pack: ${pack.title}`,
|
|
13
|
+
'',
|
|
14
|
+
pack.description,
|
|
15
|
+
'',
|
|
16
|
+
'```json',
|
|
17
|
+
JSON.stringify(pack.config, null, 2),
|
|
18
|
+
'```'
|
|
19
|
+
].join('\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderPolicyPackList() {
|
|
23
|
+
return ['Available AWGuard policy packs:', ...policyPackNames.map((name) => `- ${name}`)].join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const schemaUrl =
|
|
27
|
+
'https://raw.githubusercontent.com/Mughal-Baig/agentic-workflow-guard/main/schemas/awguard.config.schema.json';
|
|
28
|
+
|
|
29
|
+
const policyPacks = {
|
|
30
|
+
oss: {
|
|
31
|
+
title: 'Open Source Maintainer',
|
|
32
|
+
description:
|
|
33
|
+
'A practical starting point for public repositories that want visibility without too much friction.',
|
|
34
|
+
config: {
|
|
35
|
+
$schema: schemaUrl,
|
|
36
|
+
extends: ['strict'],
|
|
37
|
+
scan: {
|
|
38
|
+
include: ['.github/workflows/*', 'AGENTS.md', '.github/agents/*', '.github/prompts/*', '.mcp.json'],
|
|
39
|
+
exclude: ['node_modules/*', 'dist/*', 'build/*']
|
|
40
|
+
},
|
|
41
|
+
policy: {
|
|
42
|
+
approvedFiles: ['AGENTS.md', '.github/workflows/*'],
|
|
43
|
+
approvedMcpServers: [],
|
|
44
|
+
approvedMcpPackages: [],
|
|
45
|
+
approvedMcpPackageScopes: ['@modelcontextprotocol/'],
|
|
46
|
+
approvedMcpCommands: ['node', 'npx', 'uvx', 'docker']
|
|
47
|
+
},
|
|
48
|
+
suppressions: {
|
|
49
|
+
minimumReasonLength: 20
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
strict: {
|
|
54
|
+
title: 'Strict Repository',
|
|
55
|
+
description: 'A tighter pack for repositories where agentic surfaces should be reviewed before use.',
|
|
56
|
+
config: {
|
|
57
|
+
$schema: schemaUrl,
|
|
58
|
+
extends: ['strict'],
|
|
59
|
+
scan: {
|
|
60
|
+
include: ['.github/workflows/*', 'AGENTS.md', 'CLAUDE.md', 'CODEX.md', '.github/**', '.mcp.json', '.vscode/mcp.json'],
|
|
61
|
+
exclude: ['node_modules/*', 'dist/*', 'build/*', 'coverage/*']
|
|
62
|
+
},
|
|
63
|
+
policy: {
|
|
64
|
+
approvedFiles: [],
|
|
65
|
+
approvedMcpServers: [],
|
|
66
|
+
approvedMcpPackages: [],
|
|
67
|
+
approvedMcpPackageScopes: [],
|
|
68
|
+
approvedMcpCommands: []
|
|
69
|
+
},
|
|
70
|
+
suppressions: {
|
|
71
|
+
allowedRules: [],
|
|
72
|
+
minimumReasonLength: 30
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
enterprise: {
|
|
77
|
+
title: 'Enterprise MCP Governance',
|
|
78
|
+
description: 'A pack for organizations that want pinned MCP startup and explicit agent surface review.',
|
|
79
|
+
config: {
|
|
80
|
+
$schema: schemaUrl,
|
|
81
|
+
extends: ['strict'],
|
|
82
|
+
scan: {
|
|
83
|
+
include: ['.github/workflows/*', 'AGENTS.md', 'CLAUDE.md', 'CODEX.md', '.github/**', '.cursor/**', '.mcp.json', '.vscode/mcp.json'],
|
|
84
|
+
exclude: ['node_modules/*', 'vendor/*', 'dist/*', 'build/*', 'coverage/*']
|
|
85
|
+
},
|
|
86
|
+
policy: {
|
|
87
|
+
approvedFiles: ['AGENTS.md'],
|
|
88
|
+
approvedMcpServers: [],
|
|
89
|
+
approvedMcpPackages: [],
|
|
90
|
+
approvedMcpPackageScopes: ['@modelcontextprotocol/'],
|
|
91
|
+
approvedMcpCommands: ['node', 'docker']
|
|
92
|
+
},
|
|
93
|
+
suppressions: {
|
|
94
|
+
allowedRules: ['AWG010'],
|
|
95
|
+
minimumReasonLength: 40
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { classifyScanFile } from './scanner.js';
|
|
4
|
+
|
|
5
|
+
const mcpRunnerCommands = new Set(['npx', 'pnpx', 'bunx', 'uvx', 'pipx']);
|
|
6
|
+
const schemaUrl =
|
|
7
|
+
'https://raw.githubusercontent.com/Mughal-Baig/agentic-workflow-guard/main/schemas/awguard.config.schema.json';
|
|
8
|
+
|
|
9
|
+
export function buildPolicyWizard(result, { existingConfig = {} } = {}) {
|
|
10
|
+
const policy = existingConfig.policy || {};
|
|
11
|
+
const approvedFiles = mergeUnique(policy.approvedFiles || [], result.scannedFiles.map((file) => relativeFile(result.root, file)));
|
|
12
|
+
const mcp = collectMcpAllowlist(result);
|
|
13
|
+
|
|
14
|
+
const baseConfig = Object.keys(existingConfig).length > 0 ? existingConfig : { $schema: schemaUrl, extends: ['strict'] };
|
|
15
|
+
const config = {
|
|
16
|
+
...baseConfig,
|
|
17
|
+
policy: {
|
|
18
|
+
approvedFiles,
|
|
19
|
+
approvedMcpServers: mergeUnique(policy.approvedMcpServers || [], mcp.servers),
|
|
20
|
+
approvedMcpPackages: mergeUnique(policy.approvedMcpPackages || [], mcp.packages),
|
|
21
|
+
approvedMcpPackageScopes: mergeUnique(policy.approvedMcpPackageScopes || [], mcp.packageScopes),
|
|
22
|
+
approvedMcpCommands: mergeUnique(policy.approvedMcpCommands || [], mcp.commands)
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
summary: {
|
|
28
|
+
approvedFiles: config.policy.approvedFiles.length,
|
|
29
|
+
approvedMcpServers: config.policy.approvedMcpServers.length,
|
|
30
|
+
approvedMcpPackages: config.policy.approvedMcpPackages.length,
|
|
31
|
+
approvedMcpPackageScopes: config.policy.approvedMcpPackageScopes.length,
|
|
32
|
+
approvedMcpCommands: config.policy.approvedMcpCommands.length
|
|
33
|
+
},
|
|
34
|
+
config,
|
|
35
|
+
reviewSteps: [
|
|
36
|
+
'Review every approved file before committing the policy.',
|
|
37
|
+
'Pin mutable MCP packages and containers before approving them.',
|
|
38
|
+
'Keep command allowlists narrow; prefer node, npx, uvx, and docker only when reviewed.'
|
|
39
|
+
]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderPolicyWizard(wizard, { format = 'markdown' } = {}) {
|
|
44
|
+
if (format === 'json') return JSON.stringify(wizard.config, null, 2);
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
'# AWGuard Policy Wizard',
|
|
48
|
+
'',
|
|
49
|
+
'| Allowlist | Count |',
|
|
50
|
+
'| --- | ---: |',
|
|
51
|
+
`| Files | ${wizard.summary.approvedFiles} |`,
|
|
52
|
+
`| MCP servers | ${wizard.summary.approvedMcpServers} |`,
|
|
53
|
+
`| MCP packages | ${wizard.summary.approvedMcpPackages} |`,
|
|
54
|
+
`| MCP package scopes | ${wizard.summary.approvedMcpPackageScopes} |`,
|
|
55
|
+
`| MCP commands | ${wizard.summary.approvedMcpCommands} |`,
|
|
56
|
+
'',
|
|
57
|
+
'Review steps:',
|
|
58
|
+
'',
|
|
59
|
+
...wizard.reviewSteps.map((step) => `- ${step}`),
|
|
60
|
+
'',
|
|
61
|
+
'Starter config:',
|
|
62
|
+
'',
|
|
63
|
+
'```json',
|
|
64
|
+
JSON.stringify(wizard.config, null, 2),
|
|
65
|
+
'```'
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function collectMcpAllowlist(result) {
|
|
70
|
+
const allowlist = {
|
|
71
|
+
servers: [],
|
|
72
|
+
packages: [],
|
|
73
|
+
packageScopes: [],
|
|
74
|
+
commands: []
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for (const file of result.scannedFiles) {
|
|
78
|
+
if (classifyScanFile(file, result.root) !== 'mcp-config') continue;
|
|
79
|
+
const parsed = parseJsonLike(fs.readFileSync(file, 'utf8'));
|
|
80
|
+
if (!parsed) continue;
|
|
81
|
+
|
|
82
|
+
for (const server of collectMcpServers(parsed)) {
|
|
83
|
+
if (server.name) allowlist.servers.push(server.name);
|
|
84
|
+
const command = normalizeCommand(server.config.command);
|
|
85
|
+
if (command) allowlist.commands.push(command);
|
|
86
|
+
const packageSpec = findPackageSpec(command, arrayOfStrings(server.config.args));
|
|
87
|
+
if (packageSpec) allowlist.packages.push(packageSpec);
|
|
88
|
+
const packageScope = packageScopeFromSpec(packageSpec);
|
|
89
|
+
if (packageScope) allowlist.packageScopes.push(packageScope);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
servers: uniqueSorted(allowlist.servers),
|
|
95
|
+
packages: uniqueSorted(allowlist.packages),
|
|
96
|
+
packageScopes: uniqueSorted(allowlist.packageScopes),
|
|
97
|
+
commands: uniqueSorted(allowlist.commands)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function collectMcpServers(config) {
|
|
102
|
+
const servers = [];
|
|
103
|
+
collectMcpContainer(config.mcpServers, servers);
|
|
104
|
+
collectMcpContainer(config.servers, servers);
|
|
105
|
+
if (config.projects && typeof config.projects === 'object') {
|
|
106
|
+
for (const project of Object.values(config.projects)) {
|
|
107
|
+
collectMcpContainer(project?.mcpServers, servers);
|
|
108
|
+
collectMcpContainer(project?.servers, servers);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return servers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function collectMcpContainer(container, servers) {
|
|
115
|
+
if (!container || typeof container !== 'object' || Array.isArray(container)) return;
|
|
116
|
+
for (const [name, config] of Object.entries(container)) {
|
|
117
|
+
if (config && typeof config === 'object' && !Array.isArray(config)) {
|
|
118
|
+
servers.push({ name, config });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseJsonLike(text) {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(stripJsonComments(text).replace(/,\s*([}\]])/g, '$1'));
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stripJsonComments(text) {
|
|
132
|
+
return text.replace(/(^|\s)\/\/.*$/gm, '$1').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findPackageSpec(command, args) {
|
|
136
|
+
if (!mcpRunnerCommands.has(command)) return '';
|
|
137
|
+
return args.find((arg) => !arg.startsWith('-') && !arg.includes('=')) || '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function packageScopeFromSpec(packageSpec = '') {
|
|
141
|
+
if (!packageSpec.startsWith('@')) return '';
|
|
142
|
+
const slashIndex = packageSpec.indexOf('/');
|
|
143
|
+
return slashIndex === -1 ? '' : packageSpec.slice(0, slashIndex + 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function arrayOfStrings(value) {
|
|
147
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeCommand(value) {
|
|
151
|
+
if (typeof value !== 'string') return '';
|
|
152
|
+
return path.basename(value).toLowerCase();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function relativeFile(root, file) {
|
|
156
|
+
return path.relative(root, file).split(path.sep).join('/') || path.basename(file);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mergeUnique(existing, discovered) {
|
|
160
|
+
return uniqueSorted([...existing, ...discovered].filter(Boolean).map(String));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function uniqueSorted(values) {
|
|
164
|
+
return [...new Set(values)].sort();
|
|
165
|
+
}
|