agent-mcp-guard 0.4.3 → 0.4.4

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/README.md CHANGED
@@ -19,7 +19,7 @@ Live demo PR: [mcp-guard-demo#1](https://github.com/ChaoYue0307/mcp-guard-demo/p
19
19
  <a href="https://github.com/marketplace/actions/mcp-guard-mcp-security-scanner"><img alt="GitHub Marketplace" src="https://img.shields.io/badge/Marketplace-mcp--guard-0f766e?logo=github"></a>
20
20
  <a href="https://github.com/ChaoYue0307/mcp-guard/actions"><img alt="CI" src="https://github.com/ChaoYue0307/mcp-guard/actions/workflows/ci.yml/badge.svg"></a>
21
21
  <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-111827"></a>
22
- <a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.4.3"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
22
+ <a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.4.4"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
23
23
  </p>
24
24
 
25
25
  ## Install
@@ -71,6 +71,12 @@ Use in CI:
71
71
  mcp-guard scan --config .mcp.json --fail-on high
72
72
  ```
73
73
 
74
+ Enforce a team policy for approved commands, packages, directories, and remote URLs:
75
+
76
+ ```bash
77
+ mcp-guard scan --config .mcp.json --policy .mcp-guard-policy.json --fail-on high
78
+ ```
79
+
74
80
  Accept known findings and fail only on new risk:
75
81
 
76
82
  ```bash
@@ -81,9 +87,10 @@ mcp-guard scan --config .mcp.json --baseline .mcp-guard-baseline.json --fail-on
81
87
  Use the GitHub Action:
82
88
 
83
89
  ```yaml
84
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
90
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
85
91
  with:
86
92
  config: .mcp.json
93
+ # policy: .mcp-guard-policy.json
87
94
  baseline: .mcp-guard-baseline.json
88
95
  fail-on: high
89
96
  comment-pr: "true"
@@ -117,6 +124,7 @@ For the GitHub Action workflow, inspect the public demo repository: [ChaoYue0307
117
124
  | Broad filesystem access | Home, root, Desktop, Documents, and Downloads are high-blast-radius paths. |
118
125
  | Remote MCP URLs | Data may leave the local trust boundary. |
119
126
  | Dangerous command patterns | `rm -rf`, `sudo`, `chmod 777`, and curl-pipe-shell should block review. |
127
+ | Policy violations | Teams can enforce approved commands, packages, directories, and remote URLs. |
120
128
 
121
129
  ## Team Workflow
122
130
 
@@ -129,6 +137,8 @@ For the GitHub Action workflow, inspect the public demo repository: [ChaoYue0307
129
137
 
130
138
  The GitHub Action can also post an optional pull request comment with the active finding summary.
131
139
 
140
+ For stricter governance, commit `.mcp-guard-policy.json` and define the commands, remote packages, filesystem roots, and remote MCP endpoints the team has approved. See [Policy files](docs/policy.md).
141
+
132
142
  For a guided setup, run:
133
143
 
134
144
  ```bash
@@ -193,6 +203,7 @@ Typical scope:
193
203
 
194
204
  - install and run the CLI against redacted local MCP configs;
195
205
  - create the GitHub Action workflow;
206
+ - define an initial `.mcp-guard-policy.json`;
196
207
  - generate and review an initial baseline;
197
208
  - enable PR comments and optional GitHub code scanning;
198
209
  - record missing rules or config shapes as product feedback.
@@ -203,6 +214,7 @@ Contact: [hechaoyue0307@gmail.com](mailto:hechaoyue0307@gmail.com)
203
214
 
204
215
  - [Rule reference](docs/rules.md)
205
216
  - [Baseline and allowlist](docs/baseline.md)
217
+ - [Policy files](docs/policy.md)
206
218
  - [GitHub Action](docs/github-action.md)
207
219
  - [Marketplace publishing plan](docs/marketplace.md)
208
220
  - [Privacy and security](docs/privacy-and-security.md)
package/action.yml CHANGED
@@ -19,6 +19,10 @@ inputs:
19
19
  description: Optional mcp-guard baseline/allowlist JSON path. Matching findings are accepted and do not fail the workflow.
20
20
  required: false
21
21
  default: ""
22
+ policy:
23
+ description: Optional mcp-guard policy JSON path. Leave empty to auto-load .mcp-guard-policy.json when present.
24
+ required: false
25
+ default: ""
22
26
  comment-pr:
23
27
  description: "Post or update a pull request comment with the scan summary. Requires pull-requests: write permission."
24
28
  required: false
@@ -67,6 +71,7 @@ runs:
67
71
  uses: actions/setup-node@v6
68
72
  with:
69
73
  node-version: "24"
74
+ package-manager-cache: false
70
75
 
71
76
  - name: Generate reports
72
77
  id: reports
@@ -74,6 +79,7 @@ runs:
74
79
  env:
75
80
  MCP_GUARD_CONFIG: ${{ inputs.config }}
76
81
  MCP_GUARD_BASELINE: ${{ inputs.baseline }}
82
+ MCP_GUARD_POLICY: ${{ inputs.policy }}
77
83
  MCP_GUARD_FAIL_ON: ${{ inputs.fail-on }}
78
84
  MCP_GUARD_OUTPUT_DIR: ${{ inputs.output-dir }}
79
85
  run: |
@@ -89,6 +95,9 @@ runs:
89
95
  if [ -n "${MCP_GUARD_BASELINE}" ]; then
90
96
  scan_args+=(--baseline "${MCP_GUARD_BASELINE}")
91
97
  fi
98
+ if [ -n "${MCP_GUARD_POLICY}" ]; then
99
+ scan_args+=(--policy "${MCP_GUARD_POLICY}")
100
+ fi
92
101
 
93
102
  markdown_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.md"
94
103
  html_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.html"
package/docs/baseline.md CHANGED
@@ -30,7 +30,7 @@ If the scan finds only baseline-accepted findings, the exit code is `0`. If a ne
30
30
  ## GitHub Action
31
31
 
32
32
  ```yaml
33
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
33
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
34
34
  with:
35
35
  config: .mcp.json
36
36
  baseline: .mcp-guard-baseline.json
@@ -45,7 +45,7 @@ The generated Markdown, HTML, JSON, and PR comment separate active findings from
45
45
  {
46
46
  "version": 1,
47
47
  "generatedAt": "2026-05-10T00:00:00.000Z",
48
- "toolVersion": "0.4.3",
48
+ "toolVersion": "0.4.4",
49
49
  "findings": [
50
50
  {
51
51
  "fingerprint": "mcpg_a009b2c2",
@@ -17,6 +17,7 @@ Deliverables:
17
17
  - install the CLI and GitHub Action;
18
18
  - run `mcp-guard init` or generate an equivalent workflow manually;
19
19
  - generate Markdown, HTML, JSON, and SARIF reports;
20
+ - define an initial `.mcp-guard-policy.json` for approved commands, packages, directories, and remote URLs;
20
21
  - create an initial baseline for accepted known findings;
21
22
  - enable PR comments and optional SARIF upload;
22
23
  - document missing rule requests for future product work;
@@ -39,7 +40,7 @@ I built mcp-guard, an open-source local scanner for MCP and AI agent tool config
39
40
 
40
41
  It checks for risky shell access, unpinned npx packages, broad filesystem permissions, exposed secrets, and remote MCP servers.
41
42
 
42
- It now includes `mcp-guard init`, which creates a GitHub Action workflow and can generate a baseline for accepted current findings.
43
+ It now includes `mcp-guard init`, which creates a GitHub Action workflow, can generate a baseline for accepted current findings, and can enforce a committed policy for approved MCP commands, packages, directories, and URLs.
43
44
 
44
45
  I am collecting real-world MCP and AI agent config patterns from teams using Claude, Cursor, Codex, or MCP in production-like workflows. If you can share a redacted config or run the CLI locally, your feedback can help improve the scanner's rules and reports.
45
46
  ```
@@ -4,7 +4,9 @@ Use the `mcp-guard` action to scan MCP and AI agent tool configuration in pull r
4
4
 
5
5
  The action runs the CLI from the pinned GitHub Action tag, generates Markdown, HTML, JSON, and SARIF reports, writes a job summary, uploads reports as an artifact, and fails the job when findings meet your selected severity threshold.
6
6
 
7
- It can also use a committed baseline to accept known findings and optionally post a pull request comment with only the active findings.
7
+ It can also use a committed baseline to accept known findings, enforce a committed policy file, and optionally post a pull request comment with only the active findings.
8
+
9
+ If `.mcp-guard-policy.json` is committed at the repository root, the CLI auto-loads it. Use the `policy` input when the policy file lives elsewhere.
8
10
 
9
11
  Marketplace/action repository: <https://github.com/ChaoYue0307/mcp-guard-action>
10
12
 
@@ -35,7 +37,7 @@ jobs:
35
37
  runs-on: ubuntu-latest
36
38
  steps:
37
39
  - uses: actions/checkout@v6
38
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
40
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
39
41
  with:
40
42
  config: .mcp.json
41
43
  fail-on: high
@@ -63,7 +65,7 @@ jobs:
63
65
  runs-on: ubuntu-latest
64
66
  steps:
65
67
  - uses: actions/checkout@v6
66
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
68
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
67
69
  with:
68
70
  config: .mcp.json
69
71
  fail-on: high
@@ -75,7 +77,7 @@ jobs:
75
77
  Use `fail-on: none` when you want artifacts and summaries without blocking a pull request.
76
78
 
77
79
  ```yaml
78
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
80
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
79
81
  with:
80
82
  fail-on: none
81
83
  ```
@@ -91,7 +93,7 @@ mcp-guard scan --config .mcp.json --write-baseline .mcp-guard-baseline.json
91
93
  Commit `.mcp-guard-baseline.json`, then reference it from the action:
92
94
 
93
95
  ```yaml
94
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
96
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
95
97
  with:
96
98
  config: .mcp.json
97
99
  baseline: .mcp-guard-baseline.json
@@ -100,6 +102,20 @@ Commit `.mcp-guard-baseline.json`, then reference it from the action:
100
102
 
101
103
  Reports will show active findings separately from findings accepted by the baseline.
102
104
 
105
+ ## Policy Mode
106
+
107
+ Use a policy when you want CI to enforce approved commands, packages, directories, and remote URLs.
108
+
109
+ ```yaml
110
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
111
+ with:
112
+ config: .mcp.json
113
+ policy: .mcp-guard-policy.json
114
+ fail-on: high
115
+ ```
116
+
117
+ See [Policy files](policy.md) for the file format.
118
+
103
119
  ## Inputs
104
120
 
105
121
  | Input | Default | Description |
@@ -107,6 +123,7 @@ Reports will show active findings separately from findings accepted by the basel
107
123
  | `config` | empty | Optional MCP config path. Empty scans default project and user config locations. |
108
124
  | `fail-on` | `high` | Fails the job for `critical`, `high`, `medium`, or `low` findings. Use `none` for report-only mode. |
109
125
  | `baseline` | empty | Optional baseline/allowlist JSON path. Matching findings are accepted and do not fail the workflow. |
126
+ | `policy` | empty | Optional policy JSON path. Empty auto-loads `.mcp-guard-policy.json` when present. |
110
127
  | `comment-pr` | `false` | Posts or updates a pull request comment with the scan summary. Requires `pull-requests: write`. |
111
128
  | `output-dir` | `mcp-guard-report` | Directory for generated reports. |
112
129
  | `upload-artifact` | `true` | Uploads generated reports as a workflow artifact. |
@@ -31,7 +31,7 @@ mcp-guard scan --config .mcp.json --fail-on high
31
31
  ## GitHub Action Setup
32
32
 
33
33
  ```yaml
34
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
34
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
35
35
  with:
36
36
  config: .mcp.json
37
37
  baseline: .mcp-guard-baseline.json
@@ -23,7 +23,7 @@ jobs:
23
23
  runs-on: ubuntu-latest
24
24
  steps:
25
25
  - uses: actions/checkout@v6
26
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
26
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
27
27
  with:
28
28
  config: .mcp.json
29
29
  fail-on: high
@@ -42,7 +42,7 @@ jobs:
42
42
  runs-on: ubuntu-latest
43
43
  steps:
44
44
  - uses: actions/checkout@v6
45
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
45
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
46
46
  with:
47
47
  config: .mcp.json
48
48
  fail-on: high
@@ -56,6 +56,7 @@ jobs:
56
56
  | `config` | empty | Optional MCP config path. Empty scans default project and user config locations. |
57
57
  | `fail-on` | `high` | Fails the job for `critical`, `high`, `medium`, or `low` findings. Use `none` for report-only mode. |
58
58
  | `baseline` | empty | Optional baseline/allowlist JSON path. Matching findings are accepted and do not fail the workflow. |
59
+ | `policy` | empty | Optional policy JSON path. Empty auto-loads `.mcp-guard-policy.json` when present. |
59
60
  | `comment-pr` | `false` | Posts or updates a pull request comment with the scan summary. Requires `pull-requests: write`. |
60
61
  | `output-dir` | `mcp-guard-report` | Directory for generated reports. |
61
62
  | `upload-artifact` | `true` | Uploads generated reports as a workflow artifact. |
@@ -95,13 +96,25 @@ mcp-guard scan --config .mcp.json --write-baseline .mcp-guard-baseline.json
95
96
  Then enforce only new findings:
96
97
 
97
98
  ```yaml
98
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
99
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
99
100
  with:
100
101
  config: .mcp.json
101
102
  baseline: .mcp-guard-baseline.json
102
103
  fail-on: high
103
104
  ```
104
105
 
106
+ ## Policy Mode
107
+
108
+ Commit `.mcp-guard-policy.json` or pass `policy` to enforce approved commands, remote packages, directories, and remote MCP URLs.
109
+
110
+ ```yaml
111
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
112
+ with:
113
+ config: .mcp.json
114
+ policy: .mcp-guard-policy.json
115
+ fail-on: high
116
+ ```
117
+
105
118
  ## Transparent Example
106
119
 
107
120
  Inspect a committed input config, reproduction commands, and generated Markdown, HTML, JSON, and SARIF artifacts:
@@ -82,16 +82,17 @@ Code quality
82
82
  Current release title:
83
83
 
84
84
  ```text
85
- v0.4.3
85
+ v0.4.4
86
86
  ```
87
87
 
88
88
  Release notes:
89
89
 
90
90
  ```text
91
- Trusted Publishing readiness release.
91
+ Policy enforcement release.
92
92
 
93
- - Adds a GitHub Actions workflow for npm Trusted Publishing readiness.
94
- - Keeps `mcp-guard init` for generating a GitHub Action workflow and baseline.
93
+ - Adds `.mcp-guard-policy.json` support for approved commands, packages, directories, and remote URLs.
94
+ - Adds the `policy` GitHub Action input and automatic root policy discovery.
95
+ - Adds MCP070-MCP074 policy findings.
95
96
  - Keeps Node.js 24, PR comments, artifacts, and SARIF upload support.
96
97
  ```
97
98
 
@@ -105,7 +106,7 @@ Completed:
105
106
  - README, docs, and website examples now use:
106
107
 
107
108
  ```yaml
108
- - uses: ChaoYue0307/mcp-guard-action@v0.4.3
109
+ - uses: ChaoYue0307/mcp-guard-action@v0.4.4
109
110
  ```
110
111
 
111
112
  Remaining Marketplace web step:
package/docs/policy.md ADDED
@@ -0,0 +1,50 @@
1
+ # Policy Files
2
+
3
+ Use a policy file when a team wants an explicit approval boundary for MCP servers, not just heuristic risk findings.
4
+
5
+ `mcp-guard scan` automatically loads `.mcp-guard-policy.json` from the working directory when the file exists. You can also pass a policy explicitly:
6
+
7
+ ```bash
8
+ mcp-guard scan --config .mcp.json --policy .mcp-guard-policy.json --fail-on high
9
+ ```
10
+
11
+ Disable automatic policy loading with:
12
+
13
+ ```bash
14
+ mcp-guard scan --no-policy
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```json
20
+ {
21
+ "version": 1,
22
+ "allowedCommands": ["node", "uvx"],
23
+ "allowedPackages": ["@approved/mcp-server"],
24
+ "allowedDirectories": ["./workspace"],
25
+ "allowedRemoteUrls": ["https://approved.example.com"]
26
+ }
27
+ ```
28
+
29
+ Each field is optional. Empty or omitted fields are not enforced.
30
+
31
+ ## Fields
32
+
33
+ | Field | Meaning |
34
+ | --- | --- |
35
+ | `allowedCommands` | Approved command basenames, such as `node`, `docker`, or `uvx`. |
36
+ | `allowedPackages` | Approved remote-runner package names, without requiring a version suffix. |
37
+ | `allowedDirectories` | Approved filesystem roots. Relative paths resolve from the scan working directory. |
38
+ | `allowedRemoteUrls` | Approved remote MCP URL origins or path prefixes. |
39
+
40
+ ## Policy Findings
41
+
42
+ | Rule | Severity | What it detects |
43
+ | --- | --- | --- |
44
+ | MCP070 | High | MCP server command is not in `allowedCommands`. |
45
+ | MCP071 | High | Remote package runner uses a package outside `allowedPackages`. |
46
+ | MCP072 | High | MCP server `cwd` is outside `allowedDirectories`. |
47
+ | MCP073 | High | Filesystem argument is outside `allowedDirectories`. |
48
+ | MCP074 | High | Remote MCP URL is outside `allowedRemoteUrls`. |
49
+
50
+ Policy findings are additive. A policy does not suppress the built-in rules for shell execution, unpinned packages, broad filesystem access, secret-like values, or dangerous commands.
package/docs/roadmap.md CHANGED
@@ -12,15 +12,15 @@
12
12
  - Baseline/allowlist mode for accepting known findings and failing only on new risks.
13
13
  - Optional GitHub pull request comments from the Marketplace Action.
14
14
  - `mcp-guard init` for bootstrapping a GitHub Action workflow and optional baseline.
15
+ - Policy file enforcement for approved commands, packages, directories, and remote URLs.
15
16
  - npm Trusted Publishing workflow prepared for tokenless release publishing.
16
17
 
17
18
  ## Next
18
19
 
19
20
  1. More MCP client discovery paths.
20
21
  2. Rule packs mapped to MCP security best practices.
21
- 3. Policy file for approved commands, packages, directories, and remote URLs.
22
- 4. `mcp-guard audit` mode for review-ready reports.
23
- 5. Safer default remediation snippets for common MCP servers.
22
+ 3. `mcp-guard audit` mode for review-ready reports.
23
+ 4. Safer default remediation snippets for common MCP servers.
24
24
 
25
25
  ## Later
26
26
 
package/docs/rules.md CHANGED
@@ -18,6 +18,11 @@
18
18
  | MCP050 | Critical | Dangerous command pattern such as `rm -rf`, `sudo`, `chmod 777`, or curl pipe to shell. |
19
19
  | MCP060 | Medium | Remote MCP server URL configured. |
20
20
  | MCP061 | High | Secret-like header configured for a remote MCP server. |
21
+ | MCP070 | High | MCP server command is outside `allowedCommands` policy. |
22
+ | MCP071 | High | Remote MCP package is outside `allowedPackages` policy. |
23
+ | MCP072 | High | MCP server working directory is outside `allowedDirectories` policy. |
24
+ | MCP073 | High | Filesystem argument is outside `allowedDirectories` policy. |
25
+ | MCP074 | High | Remote MCP URL is outside `allowedRemoteUrls` policy. |
21
26
 
22
27
  ## Severity Model
23
28
 
@@ -32,4 +37,3 @@
32
37
  - The scanner does not upload configs.
33
38
  - Detection is heuristic and will miss some risks.
34
39
  - A clean report is not a security guarantee.
35
-
@@ -36,7 +36,7 @@ Configure this once on npmjs.com:
36
36
  After this is saved, run the workflow from GitHub Actions with the release tag, for example:
37
37
 
38
38
  ```text
39
- v0.4.3
39
+ v0.4.4
40
40
  ```
41
41
 
42
42
  ## Release Flow After Setup
@@ -44,7 +44,7 @@ v0.4.3
44
44
  1. Update `package.json` and `src/cli.js`.
45
45
  2. Run `npm test` and `npm run release:check`.
46
46
  3. Commit and push to `main`.
47
- 4. Create a GitHub release tag such as `v0.4.3`.
47
+ 4. Create a GitHub release tag such as `v0.4.4`.
48
48
  5. Run the `Publish npm` workflow with the same tag.
49
49
  6. Verify npm:
50
50
 
@@ -0,0 +1,16 @@
1
+ {
2
+ "version": 1,
3
+ "allowedCommands": [
4
+ "node",
5
+ "uvx"
6
+ ],
7
+ "allowedPackages": [
8
+ "@approved/mcp-server"
9
+ ],
10
+ "allowedDirectories": [
11
+ "./workspace"
12
+ ],
13
+ "allowedRemoteUrls": [
14
+ "https://approved.example.com"
15
+ ]
16
+ }
@@ -1,6 +1,6 @@
1
1
  # mcp-guard Scan Report
2
2
 
3
- Generated: 2026-05-10T13:47:08.616Z
3
+ Generated: 2026-05-10T14:01:29.032Z
4
4
 
5
5
  ## Summary
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mcp-guard",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Open-source CLI scanner for risky MCP server and AI agent tool configuration.",
5
5
  "type": "module",
6
6
  "homepage": "https://chaoyue0307.github.io/mcp-guard/",
@@ -29,6 +29,9 @@ lines.push(`Active findings: **${report.summary.findingCount}**`);
29
29
  if (acceptedCount > 0 || report.baseline?.enabled) {
30
30
  lines.push(`Accepted by baseline: **${acceptedCount}**`);
31
31
  }
32
+ if (report.policy?.path) {
33
+ lines.push(`Policy: **${report.policy.path}**`);
34
+ }
32
35
  lines.push(`Fail threshold: **${failOn || "high"}**`);
33
36
  if (runUrl) {
34
37
  lines.push(`Workflow run: ${runUrl}`);
@@ -33,6 +33,9 @@ lines.push(`Active findings: **${report.summary.findingCount}**`);
33
33
  if (acceptedCount > 0 || report.baseline?.enabled) {
34
34
  lines.push(`Accepted by baseline: **${acceptedCount}**`);
35
35
  }
36
+ if (report.policy?.path) {
37
+ lines.push(`Policy: **${report.policy.path}**`);
38
+ }
36
39
  lines.push(`Fail threshold: **${failOn || "high"}**`);
37
40
  lines.push("");
38
41
 
@@ -58,6 +58,7 @@ function validateExport() {
58
58
  "package.json",
59
59
  "bin/mcp-guard.js",
60
60
  "src/cli.js",
61
+ "src/policy.js",
61
62
  "src/report.js",
62
63
  "scripts/action-summary.js",
63
64
  "scripts/action-comment.js"
@@ -297,7 +297,7 @@
297
297
  <div class="metric"><strong>1</strong><span>Scanned files</span></div>
298
298
  <div class="metric"><strong>3</strong><span>MCP servers</span></div>
299
299
  <div class="metric"><strong>9</strong><span>Active findings</span></div>
300
- <div class="metric"><strong>2026-05-10 13:47 UTC</strong><span>Generated</span></div>
300
+ <div class="metric"><strong>2026-05-10 14:01 UTC</strong><span>Generated</span></div>
301
301
  </div>
302
302
  </div>
303
303
  <aside class="scorecard" aria-label="Risk score">
@@ -324,6 +324,8 @@
324
324
  <div class="table-wrap"><table><thead><tr><th>Path</th></tr></thead><tbody><tr><td><code>site/e2e/claude_desktop_config.json</code></td></tr></tbody></table></div>
325
325
  </section>
326
326
 
327
+
328
+
327
329
  <section>
328
330
  <h2>MCP Server Inventory</h2>
329
331
  <div class="table-wrap"><table>
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "metadata": {
3
- "generatedAt": "2026-05-10T13:47:08.576Z",
3
+ "generatedAt": "2026-05-10T14:01:28.980Z",
4
4
  "cwd": ".",
5
5
  "home": "~",
6
- "toolVersion": "0.4.3"
6
+ "policyPath": "",
7
+ "policyEnabled": false,
8
+ "toolVersion": "0.4.4"
7
9
  },
10
+ "policy": null,
8
11
  "scannedFiles": [
9
12
  "site/e2e/claude_desktop_config.json"
10
13
  ],
@@ -1,6 +1,6 @@
1
1
  # mcp-guard Scan Report
2
2
 
3
- Generated: 2026-05-10T13:47:08.555Z
3
+ Generated: 2026-05-10T14:01:28.971Z
4
4
 
5
5
  ## Summary
6
6
 
@@ -7,7 +7,7 @@
7
7
  "driver": {
8
8
  "name": "mcp-guard",
9
9
  "informationUri": "https://github.com/ChaoYue0307/mcp-guard",
10
- "semanticVersion": "0.4.3",
10
+ "semanticVersion": "0.4.4",
11
11
  "rules": [
12
12
  {
13
13
  "id": "MCP010",
package/src/baseline.js CHANGED
@@ -107,6 +107,8 @@ export function summarize(findings, servers, scannedFiles, acceptedFindingCount
107
107
  }
108
108
  }
109
109
 
110
+ const rawRiskScore = counts.critical * 20 + counts.high * 10 + counts.medium * 4 + counts.low;
111
+
110
112
  return {
111
113
  scannedFileCount: scannedFiles.length,
112
114
  serverCount: servers.length,
@@ -115,7 +117,7 @@ export function summarize(findings, servers, scannedFiles, acceptedFindingCount
115
117
  acceptedFindingCount,
116
118
  totalFindingCount: findings.length + acceptedFindingCount,
117
119
  counts,
118
- riskScore: counts.critical * 20 + counts.high * 10 + counts.medium * 4 + counts.low
120
+ riskScore: Math.min(100, rawRiskScore)
119
121
  };
120
122
  }
121
123
 
package/src/cli.js CHANGED
@@ -6,7 +6,7 @@ import { scan } from "./scan.js";
6
6
  import { generateHtmlReport, generateJsonReport, generateMarkdownReport, generateSarifReport, generateTextReport } from "./report.js";
7
7
  import { compareSeverity, severityRank } from "./severity.js";
8
8
 
9
- const VERSION = "0.4.3";
9
+ const VERSION = "0.4.4";
10
10
 
11
11
  export async function runCli(argv, io) {
12
12
  const args = argv.slice(2);
@@ -36,6 +36,7 @@ export async function runCli(argv, io) {
36
36
  includeDefaults: options.includeDefaults,
37
37
  workflowPath: options.workflowPath,
38
38
  baselinePath: options.baselinePath,
39
+ policyPath: options.policyPath,
39
40
  failOn: options.failOn,
40
41
  commentPr: options.commentPr,
41
42
  uploadSarif: options.uploadSarif,
@@ -68,6 +69,8 @@ export async function runCli(argv, io) {
68
69
  env: io.env,
69
70
  configPaths: options.configPaths,
70
71
  includeDefaults: options.includeDefaults,
72
+ policyPath: options.policyPath,
73
+ includePolicy: options.includePolicy,
71
74
  toolVersion: VERSION
72
75
  });
73
76
 
@@ -106,6 +109,7 @@ function parseInitArgs(args, defaultCwd) {
106
109
  includeDefaults: true,
107
110
  workflowPath: "",
108
111
  baselinePath: "",
112
+ policyPath: "",
109
113
  failOn: "high",
110
114
  commentPr: true,
111
115
  uploadSarif: false,
@@ -119,6 +123,7 @@ function parseInitArgs(args, defaultCwd) {
119
123
  options.baselinePath = path.join(options.cwd, ".mcp-guard-baseline.json");
120
124
  let workflowPathProvided = false;
121
125
  let baselinePathProvided = false;
126
+ let policyPathProvided = false;
122
127
 
123
128
  for (let index = 0; index < args.length; index += 1) {
124
129
  const arg = args[index];
@@ -134,6 +139,10 @@ function parseInitArgs(args, defaultCwd) {
134
139
  options.useBaseline = true;
135
140
  baselinePathProvided = true;
136
141
  index += 1;
142
+ } else if (arg === "--policy") {
143
+ options.policyPath = resolveInputPath(readValue(args, index, arg), options.cwd);
144
+ policyPathProvided = true;
145
+ index += 1;
137
146
  } else if (arg === "--write-baseline" || arg === "--write-allowlist") {
138
147
  options.writeBaseline = true;
139
148
  options.useBaseline = true;
@@ -160,6 +169,9 @@ function parseInitArgs(args, defaultCwd) {
160
169
  if (!baselinePathProvided) {
161
170
  options.baselinePath = path.join(options.cwd, ".mcp-guard-baseline.json");
162
171
  }
172
+ if (!policyPathProvided && options.policyPath) {
173
+ options.policyPath = path.join(options.cwd, ".mcp-guard-policy.json");
174
+ }
163
175
  index += 1;
164
176
  } else if (arg === "--no-defaults") {
165
177
  options.includeDefaults = false;
@@ -185,6 +197,8 @@ function parseScanArgs(args, defaultCwd) {
185
197
  failOn: "none",
186
198
  baselinePath: "",
187
199
  writeBaselinePath: "",
200
+ policyPath: "",
201
+ includePolicy: true,
188
202
  baselineReason: "Accepted current MCP findings"
189
203
  };
190
204
 
@@ -217,9 +231,14 @@ function parseScanArgs(args, defaultCwd) {
217
231
  } else if (arg === "--baseline-reason") {
218
232
  options.baselineReason = readValue(args, index, arg);
219
233
  index += 1;
234
+ } else if (arg === "--policy") {
235
+ options.policyPath = resolveInputPath(readValue(args, index, arg), options.cwd);
236
+ index += 1;
220
237
  } else if (arg === "--cwd") {
221
238
  options.cwd = path.resolve(readValue(args, index, arg));
222
239
  index += 1;
240
+ } else if (arg === "--no-policy") {
241
+ options.includePolicy = false;
223
242
  } else if (arg === "--no-defaults") {
224
243
  options.includeDefaults = false;
225
244
  } else {
@@ -279,6 +298,7 @@ Init options:
279
298
  -c, --config <path> MCP config path to reference in the workflow. Can be repeated for baseline generation.
280
299
  --fail-on <severity> Workflow fail threshold. Default: high.
281
300
  --baseline <path> Reference an existing baseline JSON file in the workflow.
301
+ --policy <path> Reference an MCP guard policy file in the workflow.
282
302
  --write-baseline Generate a baseline from current findings and reference it in the workflow.
283
303
  --baseline-reason <text>
284
304
  Reason stored for newly written baseline entries.
@@ -301,7 +321,10 @@ Scan options:
301
321
  Write current findings to a baseline JSON file.
302
322
  --baseline-reason <text>
303
323
  Reason stored for newly written baseline entries.
324
+ --policy <path> Enforce an explicit policy file.
325
+ Default: auto-load .mcp-guard-policy.json when present.
304
326
  --cwd <path> Working directory for project config discovery.
327
+ --no-policy Do not auto-load .mcp-guard-policy.json.
305
328
  --no-defaults Only scan paths passed with --config.
306
329
 
307
330
  Examples:
@@ -312,6 +335,7 @@ Examples:
312
335
  mcp-guard scan --format html --output mcp-guard-report.html
313
336
  mcp-guard scan --format sarif --output mcp-guard.sarif
314
337
  mcp-guard scan --config .mcp.json --fail-on high
338
+ mcp-guard scan --config .mcp.json --policy .mcp-guard-policy.json --fail-on high
315
339
  mcp-guard scan --config .mcp.json --write-baseline .mcp-guard-baseline.json
316
340
  mcp-guard scan --config .mcp.json --baseline .mcp-guard-baseline.json --fail-on high
317
341
  `;
package/src/init.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { writeBaselineFile } from "./baseline.js";
4
4
  import { discoverConfigFiles } from "./discovery.js";
5
5
  import { displayPath } from "./fingerprint.js";
6
+ import { DEFAULT_POLICY_FILE } from "./policy.js";
6
7
  import { scan } from "./scan.js";
7
8
 
8
9
  export async function initProject({
@@ -12,6 +13,7 @@ export async function initProject({
12
13
  includeDefaults = true,
13
14
  workflowPath,
14
15
  baselinePath,
16
+ policyPath = "",
15
17
  failOn = "high",
16
18
  commentPr = true,
17
19
  uploadSarif = false,
@@ -30,6 +32,7 @@ export async function initProject({
30
32
  explicitConfigPaths: configPaths,
31
33
  discoveredConfigPaths
32
34
  });
35
+ const workflowPolicyPath = policyPath || await defaultPolicyPath(cwd);
33
36
  const files = [];
34
37
 
35
38
  if (writeBaseline) {
@@ -46,6 +49,7 @@ export async function initProject({
46
49
  env,
47
50
  configPaths: baselineConfigPaths,
48
51
  includeDefaults: false,
52
+ includePolicy: false,
49
53
  toolVersion
50
54
  });
51
55
  const baseline = await writeBaselineFileIfAllowed(baselinePath, result, {
@@ -65,6 +69,7 @@ export async function initProject({
65
69
  actionRef: `ChaoYue0307/mcp-guard-action@v${toolVersion}`,
66
70
  configPath: workflowConfigPath ? displayPath(workflowConfigPath, cwd) : "",
67
71
  baselinePath: useBaseline || writeBaseline ? displayPath(baselinePath, cwd) : "",
72
+ policyPath: workflowPolicyPath ? displayPath(workflowPolicyPath, cwd) : "",
68
73
  failOn,
69
74
  commentPr,
70
75
  uploadSarif
@@ -80,10 +85,11 @@ export async function initProject({
80
85
  dryRun,
81
86
  workflowPath,
82
87
  baselinePath,
88
+ policyPath: workflowPolicyPath,
83
89
  configPath: workflowConfigPath,
84
90
  discoveredConfigPaths,
85
91
  files,
86
- nextSteps: buildNextSteps({ workflowPath, baselinePath, writeBaseline, uploadSarif })
92
+ nextSteps: buildNextSteps({ workflowPath, baselinePath, policyPath: workflowPolicyPath, writeBaseline, uploadSarif })
87
93
  };
88
94
  }
89
95
 
@@ -99,6 +105,9 @@ export function renderInitSummary(result, cwd) {
99
105
  } else {
100
106
  lines.push("Config: default discovery paths");
101
107
  }
108
+ if (result.policyPath) {
109
+ lines.push(`Policy: ${displayPath(result.policyPath, cwd)}`);
110
+ }
102
111
 
103
112
  for (const file of result.files) {
104
113
  const label = file.action === "skipped" ? "Skipped" : actionLabel(file.action);
@@ -115,7 +124,7 @@ export function renderInitSummary(result, cwd) {
115
124
  return `${lines.join("\n")}\n`;
116
125
  }
117
126
 
118
- export function renderGithubWorkflow({ actionRef, configPath, baselinePath, failOn, commentPr, uploadSarif }) {
127
+ export function renderGithubWorkflow({ actionRef, configPath, baselinePath, policyPath, failOn, commentPr, uploadSarif }) {
119
128
  const permissions = [" contents: read"];
120
129
  if (commentPr) {
121
130
  permissions.push(" pull-requests: write");
@@ -131,6 +140,9 @@ export function renderGithubWorkflow({ actionRef, configPath, baselinePath, fail
131
140
  if (baselinePath) {
132
141
  inputs.push(` baseline: ${quoteYaml(baselinePath)}`);
133
142
  }
143
+ if (policyPath) {
144
+ inputs.push(` policy: ${quoteYaml(policyPath)}`);
145
+ }
134
146
  inputs.push(` fail-on: ${quoteYaml(failOn)}`);
135
147
  if (commentPr) {
136
148
  inputs.push(' comment-pr: "true"');
@@ -208,6 +220,11 @@ async function writeTextFileIfAllowed(filePath, content, { force, dryRun }) {
208
220
  };
209
221
  }
210
222
 
223
+ async function defaultPolicyPath(cwd) {
224
+ const filePath = path.join(cwd, DEFAULT_POLICY_FILE);
225
+ return await fileExists(filePath) ? filePath : "";
226
+ }
227
+
211
228
  async function fileExists(filePath) {
212
229
  try {
213
230
  await fs.access(filePath);
@@ -217,7 +234,7 @@ async function fileExists(filePath) {
217
234
  }
218
235
  }
219
236
 
220
- function buildNextSteps({ workflowPath, baselinePath, writeBaseline, uploadSarif }) {
237
+ function buildNextSteps({ workflowPath, baselinePath, policyPath, writeBaseline, uploadSarif }) {
221
238
  const steps = [
222
239
  `Review ${path.basename(workflowPath)} before committing it.`,
223
240
  "Run mcp-guard scan locally and confirm the findings are expected.",
@@ -228,6 +245,10 @@ function buildNextSteps({ workflowPath, baselinePath, writeBaseline, uploadSarif
228
245
  steps.splice(1, 0, `Review ${path.basename(baselinePath)} because accepted findings will not fail CI.`);
229
246
  }
230
247
 
248
+ if (policyPath) {
249
+ steps.splice(1, 0, `Review ${path.basename(policyPath)} because it defines approved commands, packages, directories, and remote URLs.`);
250
+ }
251
+
231
252
  if (uploadSarif) {
232
253
  steps.push("Confirm GitHub code scanning is enabled for SARIF results.");
233
254
  }
package/src/policy.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const DEFAULT_POLICY_FILE = ".mcp-guard-policy.json";
5
+
6
+ export async function loadPolicy({ cwd, home, policyPath = "", includePolicy = true }) {
7
+ if (!includePolicy) return null;
8
+
9
+ const explicit = Boolean(policyPath);
10
+ const resolvedPath = explicit ? resolvePolicyPath(policyPath, cwd) : path.join(cwd, DEFAULT_POLICY_FILE);
11
+
12
+ if (!explicit && !(await fileExists(resolvedPath))) {
13
+ return null;
14
+ }
15
+
16
+ let raw;
17
+ try {
18
+ raw = JSON.parse(await fs.readFile(resolvedPath, "utf8"));
19
+ } catch (error) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ throw new Error(`Invalid policy file ${resolvedPath}: ${message}`);
22
+ }
23
+
24
+ return normalizePolicy(raw, resolvedPath, { cwd, home });
25
+ }
26
+
27
+ export function policyForReport(policy) {
28
+ if (!policy) return null;
29
+ return {
30
+ path: policy.path,
31
+ version: policy.version,
32
+ allowedCommands: [...policy.allowedCommands],
33
+ allowedPackages: [...policy.allowedPackages],
34
+ allowedDirectories: [...policy.allowedDirectories],
35
+ allowedRemoteUrls: [...policy.allowedRemoteUrls]
36
+ };
37
+ }
38
+
39
+ function normalizePolicy(raw, policyPath, context) {
40
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
41
+ throw new Error("Policy must be a JSON object.");
42
+ }
43
+
44
+ const allowedCommands = stringArray(raw.allowedCommands, "allowedCommands")
45
+ .map((command) => path.basename(command).toLowerCase());
46
+ const allowedPackages = stringArray(raw.allowedPackages, "allowedPackages")
47
+ .map(packageIdentity);
48
+ const allowedDirectories = stringArray(raw.allowedDirectories, "allowedDirectories");
49
+ const allowedRemoteUrls = stringArray(raw.allowedRemoteUrls, "allowedRemoteUrls")
50
+ .map(normalizePolicyUrl);
51
+
52
+ return {
53
+ path: policyPath,
54
+ version: raw.version || 1,
55
+ allowedCommands: new Set(allowedCommands),
56
+ allowedPackages: new Set(allowedPackages),
57
+ allowedDirectories,
58
+ allowedDirectoryPaths: allowedDirectories.map((directory) => normalizePolicyPath(directory, context)),
59
+ allowedRemoteUrls,
60
+ raw: raw
61
+ };
62
+ }
63
+
64
+ function stringArray(value, fieldName) {
65
+ if (value == null) return [];
66
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
67
+ throw new Error(`${fieldName} must be an array of non-empty strings.`);
68
+ }
69
+ return value.map((item) => item.trim());
70
+ }
71
+
72
+ function packageIdentity(packageName) {
73
+ if (packageName.startsWith("@")) {
74
+ const secondAt = packageName.indexOf("@", 1);
75
+ return secondAt > 1 ? packageName.slice(0, secondAt) : packageName;
76
+ }
77
+ const at = packageName.lastIndexOf("@");
78
+ return at > 0 ? packageName.slice(0, at) : packageName;
79
+ }
80
+
81
+ function normalizePolicyPath(value, context) {
82
+ if (value === "~") return path.normalize(context.home);
83
+ if (value.startsWith("~/")) return path.join(context.home, value.slice(2));
84
+ if (path.isAbsolute(value)) return path.normalize(value);
85
+ return path.resolve(context.cwd, value);
86
+ }
87
+
88
+ function normalizePolicyUrl(value) {
89
+ try {
90
+ const parsed = new URL(value);
91
+ const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
92
+ return `${parsed.origin}${pathname}`;
93
+ } catch {
94
+ throw new Error(`allowedRemoteUrls contains an invalid URL: ${value}`);
95
+ }
96
+ }
97
+
98
+ function resolvePolicyPath(value, cwd) {
99
+ return path.isAbsolute(value) ? value : path.resolve(cwd, value);
100
+ }
101
+
102
+ async function fileExists(filePath) {
103
+ try {
104
+ await fs.access(filePath);
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
package/src/report.js CHANGED
@@ -12,6 +12,9 @@ export function generateTextReport(result) {
12
12
  lines.push(`Accepted by baseline: ${result.summary.acceptedFindingCount}`);
13
13
  lines.push(`Total observed findings: ${result.summary.totalFindingCount}`);
14
14
  }
15
+ if (hasPolicy(result)) {
16
+ lines.push(`Policy: ${displayPath(result.policy.path, result.metadata.cwd)}`);
17
+ }
15
18
  lines.push(`Risk score: ${result.summary.riskScore}`);
16
19
  lines.push(`Critical: ${result.summary.counts.critical} High: ${result.summary.counts.high} Medium: ${result.summary.counts.medium} Low: ${result.summary.counts.low}`);
17
20
  lines.push("");
@@ -69,6 +72,9 @@ export function generateMarkdownReport(result) {
69
72
  lines.push(`- Total observed findings: ${result.summary.totalFindingCount}`);
70
73
  lines.push(`- Baseline: \`${result.baseline.path || "enabled"}\``);
71
74
  }
75
+ if (hasPolicy(result)) {
76
+ lines.push(`- Policy: \`${displayPath(result.policy.path, result.metadata.cwd)}\``);
77
+ }
72
78
  lines.push(`- Risk score: ${result.summary.riskScore}`);
73
79
  lines.push(`- Critical: ${result.summary.counts.critical}`);
74
80
  lines.push(`- High: ${result.summary.counts.high}`);
@@ -87,6 +93,17 @@ export function generateMarkdownReport(result) {
87
93
  }
88
94
  lines.push("");
89
95
 
96
+ if (hasPolicy(result)) {
97
+ lines.push("## Policy");
98
+ lines.push("");
99
+ lines.push(`- Path: \`${displayPath(result.policy.path, result.metadata.cwd)}\``);
100
+ lines.push(`- Allowed commands: ${inlineList(result.policy.allowedCommands)}`);
101
+ lines.push(`- Allowed packages: ${inlineList(result.policy.allowedPackages)}`);
102
+ lines.push(`- Allowed directories: ${inlineList(result.policy.allowedDirectories)}`);
103
+ lines.push(`- Allowed remote URLs: ${inlineList(result.policy.allowedRemoteUrls)}`);
104
+ lines.push("");
105
+ }
106
+
90
107
  lines.push("## MCP Server Inventory");
91
108
  lines.push("");
92
109
  if (result.servers.length === 0) {
@@ -510,6 +527,11 @@ export function generateHtmlReport(result) {
510
527
  ${renderScannedFiles(safeResult)}
511
528
  </section>
512
529
 
530
+ ${hasPolicy(safeResult) ? ` <section>
531
+ <h2>Policy</h2>
532
+ ${renderPolicy(safeResult)}
533
+ </section>` : ""}
534
+
513
535
  <section>
514
536
  <h2>MCP Server Inventory</h2>
515
537
  ${renderServerTable(servers, safeResult.metadata.cwd)}
@@ -549,8 +571,13 @@ function sanitizeResult(result) {
549
571
  metadata: {
550
572
  ...result.metadata,
551
573
  cwd: ".",
552
- home: "~"
574
+ home: "~",
575
+ policyPath: result.metadata.policyPath ? displayPath(result.metadata.policyPath, cwd) : ""
553
576
  },
577
+ policy: result.policy ? {
578
+ ...result.policy,
579
+ path: displayPath(result.policy.path, cwd)
580
+ } : null,
554
581
  scannedFiles: result.scannedFiles.map((file) => displayPath(file, cwd)),
555
582
  servers: result.servers.map((server) => ({
556
583
  name: server.name,
@@ -673,6 +700,19 @@ function renderScannedFiles(result) {
673
700
  return `<div class="table-wrap"><table><thead><tr><th>Path</th></tr></thead><tbody>${items}</tbody></table></div>`;
674
701
  }
675
702
 
703
+ function renderPolicy(result) {
704
+ const policy = result.policy;
705
+ const rows = [
706
+ ["Path", policy.path],
707
+ ["Allowed commands", policy.allowedCommands.join(", ") || "-"],
708
+ ["Allowed packages", policy.allowedPackages.join(", ") || "-"],
709
+ ["Allowed directories", policy.allowedDirectories.join(", ") || "-"],
710
+ ["Allowed remote URLs", policy.allowedRemoteUrls.join(", ") || "-"]
711
+ ].map(([key, value]) => `<tr><th>${escapeHtml(key)}</th><td>${codeOrDash(value)}</td></tr>`).join("");
712
+
713
+ return `<div class="table-wrap"><table><tbody>${rows}</tbody></table></div>`;
714
+ }
715
+
676
716
  function renderServerTable(servers, cwd) {
677
717
  if (servers.length === 0) {
678
718
  return `<p class="empty">No MCP servers were found.</p>`;
@@ -774,10 +814,19 @@ function hasBaseline(result) {
774
814
  return Boolean(result.baseline?.enabled);
775
815
  }
776
816
 
817
+ function hasPolicy(result) {
818
+ return Boolean(result.policy);
819
+ }
820
+
777
821
  function hasAcceptedFindings(result) {
778
822
  return (result.acceptedFindings || []).length > 0;
779
823
  }
780
824
 
825
+ function inlineList(items) {
826
+ if (!items || items.length === 0) return "not restricted";
827
+ return items.map((item) => `\`${item}\``).join(", ");
828
+ }
829
+
781
830
  function escapeHtml(value) {
782
831
  return String(value ?? "")
783
832
  .replaceAll("&", "&amp;")
package/src/rules.js CHANGED
@@ -31,6 +31,11 @@ export function evaluateServer(server, context) {
31
31
  findings.push(...ruleDangerousCommandPattern(server));
32
32
  findings.push(...ruleRemoteUrl(server));
33
33
  findings.push(...ruleHeaders(server));
34
+ findings.push(...rulePolicyAllowedCommand(server, context));
35
+ findings.push(...rulePolicyAllowedPackage(server, context));
36
+ findings.push(...rulePolicyAllowedWorkingDirectory(server, context));
37
+ findings.push(...rulePolicyAllowedFilesystemArgs(server, context));
38
+ findings.push(...rulePolicyAllowedRemoteUrl(server, context));
34
39
 
35
40
  return findings;
36
41
  }
@@ -220,6 +225,103 @@ function ruleHeaders(server) {
220
225
  return findings;
221
226
  }
222
227
 
228
+ function rulePolicyAllowedCommand(server, context) {
229
+ const policy = context.policy;
230
+ if (!policy || policy.allowedCommands.size === 0 || !server.command) return [];
231
+
232
+ const command = commandBase(server.command);
233
+ if (policy.allowedCommands.has(command)) return [];
234
+
235
+ return [finding({
236
+ id: "MCP070",
237
+ severity: "high",
238
+ title: "MCP server command is not allowed by policy",
239
+ server,
240
+ evidence: `command=${server.command} allowed=${listSet(policy.allowedCommands)}`,
241
+ recommendation: "Use an approved command, or add this command to allowedCommands only after review."
242
+ })];
243
+ }
244
+
245
+ function rulePolicyAllowedPackage(server, context) {
246
+ const policy = context.policy;
247
+ if (!policy || policy.allowedPackages.size === 0) return [];
248
+
249
+ const command = commandBase(server.command);
250
+ const usesRemoteRunner = REMOTE_EXEC_COMMANDS.has(command) || (PACKAGE_MANAGER_COMMANDS.has(command) && server.args[0] === "dlx");
251
+ if (!usesRemoteRunner) return [];
252
+
253
+ const packageArg = firstPackageArg(server.args);
254
+ if (!packageArg) return [];
255
+
256
+ const packageName = packageIdentity(packageArg);
257
+ if (policy.allowedPackages.has(packageName)) return [];
258
+
259
+ return [finding({
260
+ id: "MCP071",
261
+ severity: "high",
262
+ title: "Remote MCP package is not allowed by policy",
263
+ server,
264
+ evidence: `package=${packageName} allowed=${listSet(policy.allowedPackages)}`,
265
+ recommendation: "Use an approved MCP package, or add this package to allowedPackages only after review."
266
+ })];
267
+ }
268
+
269
+ function rulePolicyAllowedWorkingDirectory(server, context) {
270
+ const policy = context.policy;
271
+ if (!policy || policy.allowedDirectoryPaths.length === 0 || !server.cwd) return [];
272
+
273
+ const normalized = normalizePath(server.cwd, context);
274
+ if (isAllowedPath(normalized, policy.allowedDirectoryPaths)) return [];
275
+
276
+ return [finding({
277
+ id: "MCP072",
278
+ severity: "high",
279
+ title: "MCP server working directory is outside policy",
280
+ server,
281
+ evidence: `cwd=${server.cwd} allowed=${policy.allowedDirectories.join(", ")}`,
282
+ recommendation: "Move this server into an approved workspace directory, or add the directory to allowedDirectories after review."
283
+ })];
284
+ }
285
+
286
+ function rulePolicyAllowedFilesystemArgs(server, context) {
287
+ const policy = context.policy;
288
+ if (!policy || policy.allowedDirectoryPaths.length === 0) return [];
289
+
290
+ const findings = [];
291
+ for (const arg of server.args) {
292
+ const value = filesystemPathFromArg(arg);
293
+ if (!value) continue;
294
+
295
+ const normalized = normalizePath(value, context);
296
+ if (isAllowedPath(normalized, policy.allowedDirectoryPaths)) continue;
297
+
298
+ findings.push(finding({
299
+ id: "MCP073",
300
+ severity: "high",
301
+ title: "MCP server filesystem argument is outside policy",
302
+ server,
303
+ evidence: `arg=${arg} allowed=${policy.allowedDirectories.join(", ")}`,
304
+ recommendation: "Limit filesystem arguments to approved directories, or update allowedDirectories only after review."
305
+ }));
306
+ }
307
+ return findings;
308
+ }
309
+
310
+ function rulePolicyAllowedRemoteUrl(server, context) {
311
+ const policy = context.policy;
312
+ if (!policy || policy.allowedRemoteUrls.length === 0 || !server.url) return [];
313
+ if (isAllowedRemoteUrl(server.url, policy.allowedRemoteUrls)) return [];
314
+
315
+ return [finding({
316
+ id: "MCP074",
317
+ severity: "high",
318
+ title: "Remote MCP URL is not allowed by policy",
319
+ server,
320
+ evidence: `url=${server.url} allowed=${policy.allowedRemoteUrls.join(", ")}`,
321
+ recommendation: "Use an approved remote MCP endpoint, or add this URL to allowedRemoteUrls only after review."
322
+ })];
323
+ }
324
+
223
325
  function finding({ id, severity, title, server, evidence, recommendation }) {
224
326
  return {
225
327
  id,
@@ -252,6 +354,15 @@ function isPinnedPackage(packageName) {
252
354
  return at > 0 && at < packageName.length - 1;
253
355
  }
254
356
 
357
+ function packageIdentity(packageName) {
358
+ if (packageName.startsWith("@")) {
359
+ const secondAt = packageName.indexOf("@", 1);
360
+ return secondAt > 1 ? packageName.slice(0, secondAt) : packageName;
361
+ }
362
+ const at = packageName.lastIndexOf("@");
363
+ return at > 0 ? packageName.slice(0, at) : packageName;
364
+ }
365
+
255
366
  function valueFromArg(arg) {
256
367
  if (!arg) return "";
257
368
  const equalIndex = arg.indexOf("=");
@@ -260,6 +371,14 @@ function valueFromArg(arg) {
260
371
  return "";
261
372
  }
262
373
 
374
+ function filesystemPathFromArg(arg) {
375
+ const value = valueFromArg(arg);
376
+ if (!value) return "";
377
+ if (value === "~" || value.startsWith("~/")) return value;
378
+ if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../")) return value;
379
+ return "";
380
+ }
381
+
263
382
  function normalizePath(value, context) {
264
383
  if (!value) return "";
265
384
  if (value === "~") return context.home;
@@ -268,3 +387,26 @@ function normalizePath(value, context) {
268
387
  return path.resolve(context.cwd, value);
269
388
  }
270
389
 
390
+ function isAllowedPath(filePath, allowedPaths) {
391
+ return allowedPaths.some((allowedPath) => {
392
+ const normalizedAllowed = path.normalize(allowedPath);
393
+ return filePath === normalizedAllowed || filePath.startsWith(`${normalizedAllowed}${path.sep}`);
394
+ });
395
+ }
396
+
397
+ function isAllowedRemoteUrl(value, allowedUrls) {
398
+ let target;
399
+ try {
400
+ target = new URL(value);
401
+ } catch {
402
+ return false;
403
+ }
404
+
405
+ const targetPath = target.pathname === "/" ? "" : target.pathname.replace(/\/+$/, "");
406
+ const targetValue = `${target.origin}${targetPath}`;
407
+ return allowedUrls.some((allowed) => targetValue === allowed || targetValue.startsWith(`${allowed}/`));
408
+ }
409
+
410
+ function listSet(items) {
411
+ return [...items].join(", ");
412
+ }
package/src/scan.js CHANGED
@@ -3,12 +3,14 @@ import path from "node:path";
3
3
  import { extractServers, loadConfigFile } from "./config.js";
4
4
  import { discoverConfigFiles } from "./discovery.js";
5
5
  import { findingFingerprint } from "./fingerprint.js";
6
+ import { loadPolicy, policyForReport } from "./policy.js";
6
7
  import { evaluateServer } from "./rules.js";
7
8
  import { sortFindings } from "./severity.js";
8
9
  import { summarize } from "./baseline.js";
9
10
 
10
- export async function scan({ cwd, env, configPaths = [], includeDefaults = true, toolVersion = "0.0.0" }) {
11
+ export async function scan({ cwd, env, configPaths = [], includeDefaults = true, policyPath = "", includePolicy = true, toolVersion = "0.0.0" }) {
11
12
  const home = env.HOME || env.USERPROFILE || os.homedir();
13
+ const policy = await loadPolicy({ cwd, home, policyPath, includePolicy });
12
14
  const explicitPaths = configPaths.map((item) => path.resolve(item));
13
15
  const shouldDiscover = explicitPaths.length === 0 && includeDefaults;
14
16
  const discoveredPaths = shouldDiscover ? await discoverConfigFiles({ cwd, env }) : [];
@@ -63,7 +65,7 @@ export async function scan({ cwd, env, configPaths = [], includeDefaults = true,
63
65
  }
64
66
 
65
67
  for (const server of servers) {
66
- findings.push(...evaluateServer(server, { cwd, home }));
68
+ findings.push(...evaluateServer(server, { cwd, home, policy }));
67
69
  }
68
70
 
69
71
  const sortedFindings = sortFindings(findings).map((finding) => ({
@@ -76,8 +78,11 @@ export async function scan({ cwd, env, configPaths = [], includeDefaults = true,
76
78
  generatedAt: new Date().toISOString(),
77
79
  cwd,
78
80
  home,
81
+ policyPath: policy?.path || "",
82
+ policyEnabled: Boolean(policy),
79
83
  toolVersion
80
84
  },
85
+ policy: policyForReport(policy),
81
86
  scannedFiles,
82
87
  servers,
83
88
  findings: sortedFindings,