mcp-gov 1.3.1 → 2.0.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 CHANGED
@@ -2,6 +2,78 @@
2
2
 
3
3
  All notable changes to mcp-gov will be documented in this file.
4
4
 
5
+ ## [2.0.0] - 2026-05-24
6
+
7
+ Security-hardening release. Two changes are **breaking** — see Migration.
8
+
9
+ ### ⚠️ Breaking changes / Migration
10
+ - **Audit log is now line-delimited JSON**, not pipe-delimited text. Anything
11
+ that parsed `[AUDIT] … | tool=… | …` must switch to reading one JSON object
12
+ per line: `{"type":"AUDIT","timestamp","status","tool","service","operation","project"}`.
13
+ - **New installs are fail-closed.** Rules files generated by `mcp-gov-wrap` from
14
+ this version include `"defaultPolicy": "deny"` plus a complete per-service rule
15
+ set, so an operation matched by no rule is denied. Existing rules files are
16
+ **not** modified and keep the previous permissive (`allow`) default until you
17
+ add `"defaultPolicy": "deny"` yourself. To get the new posture on an existing
18
+ install, re-run `mcp-gov-wrap` against a fresh rules file or set the field.
19
+
20
+ ### Security
21
+ - **Audit log injection fixed.** Audit records are now emitted as one JSON
22
+ object per line. JSON encoding escapes newlines, quotes and control
23
+ characters in client-controlled tool/service names, so a crafted name can no
24
+ longer forge a second `[AUDIT]` line or inject delimiter-shaped fields.
25
+ - **Opt-in fail-closed policy.** Rules files now support a top-level
26
+ `"defaultPolicy"` of `"allow"` (default, backward compatible) or `"deny"`.
27
+ When `deny`, any operation not matched by an explicit rule is blocked instead
28
+ of allowed. `mcp-gov-wrap` now writes `"defaultPolicy": "deny"` into
29
+ newly-generated rules files and emits a complete rule set
30
+ (allow read/write, deny delete/execute/admin) for every service, so normal
31
+ traffic is unaffected. **Existing rules files are left unchanged** (they keep
32
+ the permissive default); add `"defaultPolicy": "deny"` to opt in.
33
+ - **Dependency surface reduced.** `axios` and `dotenv` (used only by the
34
+ bundled example, not by the CLI or library) moved from `dependencies` to
35
+ `devDependencies`, removing their known advisories from the consumer-facing
36
+ install tree.
37
+ - **Backup permissions tightened.** Config backups (which may contain secrets
38
+ copied from MCP configs) are now created with `0600` permissions, and
39
+ `*.backup-*` is gitignored.
40
+
41
+ ### Changed
42
+ - **Audit log is now line-delimited JSON** instead of the previous
43
+ pipe-delimited text (`[AUDIT] … | tool=… | …`). Each line is a JSON object
44
+ `{"type":"AUDIT","timestamp","status","tool","service","operation","project"}`.
45
+ **Breaking for anything that parsed the old text format.**
46
+ - **Unified rule loading.** Array (`rules: [...]`), legacy object
47
+ (`services: {...}`), and nested-map (`{ svc: { op } }`) rule shapes are now
48
+ normalized through one shared loader (`src/rules.js`) used by both the proxy
49
+ and `GovernedMCPServer`, removing duplicated per-format logic. All existing
50
+ shapes continue to work unchanged.
51
+ - `GovernedMCPServer` now reads `defaultPolicy` from the rules object (the
52
+ documented location), falling back to the constructor config — previously it
53
+ only checked config, so a documented `defaultPolicy` was silently ignored.
54
+
55
+ ### Fixed
56
+ - **Argument boundaries preserved when wrapping.** The proxy now receives the
57
+ target server's argv as a structured `--target-args` JSON array, so arguments
58
+ containing spaces (e.g. paths like `/Users/My Documents`) are no longer
59
+ re-split. The legacy whitespace-split of `--target` remains as a fallback for
60
+ configs wrapped by older versions.
61
+ - Leading `~`/`~/` in the interactive CLI's path prompt is expanded correctly
62
+ (a `~` elsewhere in the path is left untouched).
63
+
64
+ ### Removed
65
+ - Dead, unreachable `wrapServers`/`unwrapServers` helpers and their incorrect
66
+ `'claude-code'` format branch (the active code paths are unaffected).
67
+
68
+ ### Known issues
69
+ - Operation classification is heuristic (substring match on the tool name) and
70
+ does not inspect tool arguments; a destructive tool named without a
71
+ destructive keyword is classified `write`. Use explicit per-service rules and
72
+ `"defaultPolicy": "deny"` for stronger control. Only `tools/call` is mediated.
73
+ - `@modelcontextprotocol/sdk ^0.5.0` has known advisories; upgrading is a major
74
+ (1.x) change that requires migrating `src/index.js`. The CLI proxy path does
75
+ not depend on the SDK.
76
+
5
77
  ## [1.3.1] - 2026-01-24
6
78
 
7
79
  ### Changed
package/LICENSE CHANGED
@@ -1,21 +1,202 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Amr Hassan
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -11,6 +11,11 @@
11
11
 
12
12
  # MCP Governance System
13
13
 
14
+ <p>
15
+ <img src="https://img.shields.io/github/package-json/v/hamr0/mcp-gov?label=version&color=2a4f8c" alt="version (auto from package.json)">
16
+ <img src="https://img.shields.io/badge/license-Apache%202.0-2a4f8c" alt="license: Apache 2.0">
17
+ </p>
18
+
14
19
  </div>
15
20
 
16
21
  Permission control and audit logging for Model Context Protocol (MCP) servers.
@@ -46,7 +51,7 @@ mcp-gov
46
51
  ██║╚██╔╝██║ ██║ ██╔═══╝ ██║ ██║██║ ██║╚██╗ ██╔╝
47
52
  ██║ ╚═╝ ██║ ╚██████╗ ██║ ╚██████╔╝╚██████╔╝ ╚████╔╝
48
53
  ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═══╝
49
- v1.3.0
54
+ v2.0.0
50
55
 
51
56
  Select action:
52
57
  1) Wrap MCP servers
@@ -131,6 +136,7 @@ Rules are auto-generated at `~/.mcp-gov/rules.json`:
131
136
 
132
137
  ```json
133
138
  {
139
+ "defaultPolicy": "deny",
134
140
  "rules": [
135
141
  {
136
142
  "service": "github",
@@ -147,10 +153,27 @@ Rules are auto-generated at `~/.mcp-gov/rules.json`:
147
153
  }
148
154
  ```
149
155
 
156
+ `defaultPolicy` controls what happens when no rule matches a service/operation:
157
+
158
+ - `"deny"` — fail-closed: only explicitly-allowed operations pass (recommended).
159
+ New rules files generated by `mcp-gov-wrap` use this and include a complete
160
+ allow/deny rule set per service, so normal read/write traffic is unaffected.
161
+ - `"allow"` — fail-open: anything not explicitly denied passes. This is the
162
+ default when `defaultPolicy` is omitted, for backward compatibility. Existing
163
+ rules files keep this behavior until you add `"defaultPolicy": "deny"`.
164
+
165
+ Two older rule shapes are still accepted and normalized internally: the legacy
166
+ `{ "services": { svc: { "operations": { op: "allow"|"deny" } } } }` object, and
167
+ the library's nested map `{ svc: { op: "allow"|"deny" } }` (used by
168
+ `GovernedMCPServer`). `defaultPolicy` may be set at the top level of any of them.
169
+
150
170
  ## Audit Log Format
151
171
 
152
- ```
153
- [AUDIT] 2026-01-24T10:30:45.123Z | DENIED | tool=delete_repo | service=github | operation=delete | project=/home/user/myproject
172
+ Audit records are written one JSON object per line (so tool names can't forge
173
+ or corrupt entries). Each record:
174
+
175
+ ```json
176
+ {"type":"AUDIT","timestamp":"2026-01-24T10:30:45.123Z","status":"DENIED","tool":"delete_repo","service":"github","operation":"delete","project":"/home/user/myproject"}
154
177
  ```
155
178
 
156
179
  ## CLI Commands
@@ -168,6 +191,23 @@ mcp-gov-unwrap --config ~/.claude.json
168
191
  mcp-gov-proxy --service github --target "npx server" --rules ~/.mcp-gov/rules.json
169
192
  ```
170
193
 
194
+ ## Security Model & Limitations
195
+
196
+ MCP-GOV is a useful guardrail, not a complete sandbox. Know what it does and
197
+ does not protect:
198
+
199
+ - **Operation type is inferred from the tool *name*** (keyword matching), and
200
+ tool **arguments are not inspected**. A destructive tool whose name contains no
201
+ destructive keyword is classified `write`. For stronger control, write
202
+ explicit per-service rules and set `"defaultPolicy": "deny"`.
203
+ - **Only `tools/call` is mediated.** Other MCP methods (`resources/*`,
204
+ `prompts/*`, etc.) pass through unchecked.
205
+ - **Config backups may contain secrets** (tokens copied from your MCP config).
206
+ They are written with `0600` permissions and are gitignored (`*.backup-*`),
207
+ but you should still clean them up.
208
+ - Treat the audit log as a record, and the rules as advisory policy — not as a
209
+ hard security boundary against a fully adversarial MCP server.
210
+
171
211
  ## License
172
212
 
173
- MIT
213
+ Apache-2.0 — see [LICENSE](LICENSE).
@@ -12,6 +12,7 @@ import { createInterface } from 'node:readline';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import { extractService, detectOperation } from '../src/operation-detector.js';
15
+ import { normalizeRules, isAllowed } from '../src/rules.js';
15
16
 
16
17
  // Default audit log path
17
18
  const DEFAULT_AUDIT_LOG = join(homedir(), '.mcp-gov', 'audit.log');
@@ -32,6 +33,9 @@ function parseCliArgs() {
32
33
  type: 'string',
33
34
  short: 't',
34
35
  },
36
+ 'target-args': {
37
+ type: 'string',
38
+ },
35
39
  rules: {
36
40
  type: 'string',
37
41
  short: 'r',
@@ -123,43 +127,6 @@ function loadRules(rulesPath) {
123
127
  }
124
128
  }
125
129
 
126
- /**
127
- * Check if operation is allowed based on rules
128
- * @param {object} rules - Loaded rules object
129
- * @param {string} service - Service name
130
- * @param {string} operation - Operation type
131
- * @returns {boolean} True if allowed, false if denied
132
- */
133
- function isOperationAllowed(rules, service, operation) {
134
- // Support two rule formats:
135
- // 1. Array format (from mcp-gov-wrap): { rules: [{service, operations[], permission}] }
136
- // 2. Object format (legacy): { services: {service: {operations: {op: permission}}} }
137
-
138
- // Try array format first (generated by mcp-gov-wrap)
139
- if (rules.rules && Array.isArray(rules.rules)) {
140
- for (const rule of rules.rules) {
141
- if (rule.service === service && rule.operations && rule.operations.includes(operation)) {
142
- return rule.permission !== 'deny';
143
- }
144
- }
145
- // No matching rule found - default to allow
146
- return true;
147
- }
148
-
149
- // Try object format (legacy)
150
- if (!rules.services || !rules.services[service]) {
151
- return true;
152
- }
153
-
154
- const serviceRules = rules.services[service];
155
- if (!serviceRules.operations || !serviceRules.operations[operation]) {
156
- return true;
157
- }
158
-
159
- const permission = serviceRules.operations[operation];
160
- return permission !== 'deny';
161
- }
162
-
163
130
  /**
164
131
  * Create a JSON-RPC error response
165
132
  * @param {number|string} id - Request ID
@@ -182,17 +149,28 @@ function createErrorResponse(id, message) {
182
149
  let auditLogPath = null;
183
150
 
184
151
  /**
185
- * Log audit information to stderr and optionally to file
152
+ * Log audit information to stderr and optionally to file.
153
+ *
154
+ * Emitted as a single JSON object per line. Tool/service names originate from
155
+ * client-controlled JSON-RPC params; JSON encoding escapes newlines, quotes and
156
+ * control characters, so a crafted name cannot forge additional audit records
157
+ * or inject delimiter-shaped fields (closes the log-injection vector). The
158
+ * "type":"AUDIT" tag keeps lines greppable.
186
159
  * @param {string} toolName - Tool name
187
160
  * @param {string} service - Service name
188
161
  * @param {string} operation - Operation type
189
162
  * @param {boolean} allowed - Whether operation was allowed
190
163
  */
191
164
  function logAudit(toolName, service, operation, allowed) {
192
- const timestamp = new Date().toISOString();
193
- const status = allowed ? 'ALLOWED' : 'DENIED';
194
- const projectPath = process.cwd();
195
- const logLine = `[AUDIT] ${timestamp} | ${status} | tool=${toolName} | service=${service} | operation=${operation} | project=${projectPath}`;
165
+ const logLine = JSON.stringify({
166
+ type: 'AUDIT',
167
+ timestamp: new Date().toISOString(),
168
+ status: allowed ? 'ALLOWED' : 'DENIED',
169
+ tool: toolName,
170
+ service: service,
171
+ operation: operation,
172
+ project: process.cwd()
173
+ });
196
174
 
197
175
  // Always log to stderr
198
176
  console.error(logLine);
@@ -214,7 +192,7 @@ function logAudit(toolName, service, operation, allowed) {
214
192
  * @param {string} rulesPath - Path to rules.json file
215
193
  * @param {string} logPath - Path to audit log file (optional override)
216
194
  */
217
- function startProxy(serviceName, targetCommand, rulesPath, logPath) {
195
+ function startProxy(serviceName, targetCommand, rulesPath, logPath, targetArgsJson) {
218
196
  // Set up audit logging - organize by service
219
197
  // Default: ~/.mcp-gov/logs/<service>.log
220
198
  if (logPath) {
@@ -231,13 +209,35 @@ function startProxy(serviceName, targetCommand, rulesPath, logPath) {
231
209
  mkdirSync(logDir, { recursive: true });
232
210
  }
233
211
 
234
- // Load rules file
235
- const rules = loadRules(rulesPath);
236
-
237
- // Parse the target command (handle both "node server.js" and single commands)
238
- const commandParts = targetCommand.split(/\s+/);
239
- const command = commandParts[0];
240
- const args = commandParts.slice(1);
212
+ // Load rules file and normalize to a single canonical form (handles array,
213
+ // legacy object, and nested-map shapes; carries defaultPolicy).
214
+ const normalizedRules = normalizeRules(loadRules(rulesPath));
215
+
216
+ // Determine the command and argv for the target server.
217
+ // Prefer the structured --target-args (a JSON array [command, ...args]) which
218
+ // preserves argument boundaries; fall back to whitespace-splitting --target
219
+ // for configs wrapped by older versions that did not emit --target-args.
220
+ let command;
221
+ let args;
222
+ if (targetArgsJson) {
223
+ let argv;
224
+ try {
225
+ argv = JSON.parse(targetArgsJson);
226
+ } catch (e) {
227
+ console.error(`Error parsing --target-args (expected JSON array): ${e.message}`);
228
+ process.exit(1);
229
+ }
230
+ if (!Array.isArray(argv) || argv.length === 0) {
231
+ console.error('Error: --target-args must be a non-empty JSON array [command, ...args]');
232
+ process.exit(1);
233
+ }
234
+ command = argv[0];
235
+ args = argv.slice(1);
236
+ } else {
237
+ const commandParts = targetCommand.split(/\s+/);
238
+ command = commandParts[0];
239
+ args = commandParts.slice(1);
240
+ }
241
241
 
242
242
  // Spawn the target MCP server
243
243
  const targetServer = spawn(command, args, {
@@ -285,7 +285,7 @@ function startProxy(serviceName, targetCommand, rulesPath, logPath) {
285
285
  const operation = detectOperation(toolName);
286
286
 
287
287
  // Check permissions
288
- const allowed = isOperationAllowed(rules, service, operation);
288
+ const allowed = isAllowed(normalizedRules, service, operation);
289
289
 
290
290
  // Log audit information
291
291
  logAudit(toolName, service, operation, allowed);
@@ -356,7 +356,7 @@ function main() {
356
356
  process.exit(1);
357
357
  }
358
358
 
359
- startProxy(args.service, args.target, args.rules, args.log);
359
+ startProxy(args.service, args.target, args.rules, args.log, args['target-args']);
360
360
  }
361
361
 
362
362
  main();
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { parseArgs } from 'node:util';
9
- import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
9
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, chmodSync } from 'node:fs';
10
10
  import { exec } from 'node:child_process';
11
11
  import { promisify } from 'node:util';
12
12
 
@@ -223,40 +223,15 @@ function createBackup(configPath) {
223
223
  const backupPath = `${configPath}.backup-${timestamp}`;
224
224
 
225
225
  copyFileSync(configPath, backupPath);
226
-
227
- return backupPath;
228
- }
229
-
230
- /**
231
- * Unwrap wrapped servers in the config
232
- * @param {Object} config - Full config object with mcpServers
233
- * @param {string[]} wrappedNames - Names of servers to unwrap
234
- * @returns {Object} Modified config with unwrapped servers
235
- */
236
- function unwrapServers(config, wrappedNames) {
237
- const modifiedConfig = JSON.parse(JSON.stringify(config.rawConfig));
238
-
239
- // Get reference to mcpServers in the modified config
240
- let mcpServers;
241
- if (config.format === 'claude-code') {
242
- mcpServers = modifiedConfig.projects.mcpServers;
243
- } else {
244
- mcpServers = modifiedConfig.mcpServers;
245
- }
246
-
247
- // Unwrap each wrapped server
248
- for (const serverName of wrappedNames) {
249
- const originalConfig = mcpServers[serverName];
250
-
251
- try {
252
- mcpServers[serverName] = unwrapServer(originalConfig);
253
- } catch (error) {
254
- console.warn(`Warning: Cannot unwrap ${serverName}: ${error.message}`);
255
- // Skip this server, leave it as-is
256
- }
226
+ // MCP configs frequently embed secrets (API tokens in `env`). Restrict the
227
+ // backup to owner-only so the copy does not widen exposure beyond the original.
228
+ try {
229
+ chmodSync(backupPath, 0o600);
230
+ } catch {
231
+ // Non-POSIX filesystems may not support chmod; the copy still succeeded.
257
232
  }
258
233
 
259
- return modifiedConfig;
234
+ return backupPath;
260
235
  }
261
236
 
262
237
  /**
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { parseArgs } from 'node:util';
9
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
9
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, chmodSync } from 'node:fs';
10
10
  import { exec, spawn } from 'node:child_process';
11
11
  import { promisify } from 'node:util';
12
12
  import { resolve, dirname, join } from 'node:path';
@@ -249,27 +249,33 @@ function detectUnwrappedServers(mcpServers) {
249
249
  * @returns {Object} Wrapped server configuration
250
250
  */
251
251
  function wrapServer(serverName, serverConfig, rulesPath) {
252
- // Build target command from original config
253
- let targetCommand = serverConfig.command || '';
252
+ const originalArgs = Array.isArray(serverConfig.args) ? serverConfig.args : [];
254
253
 
255
- // Append original args if they exist
256
- if (serverConfig.args && Array.isArray(serverConfig.args)) {
257
- targetCommand += ' ' + serverConfig.args.join(' ');
254
+ // Build the human-readable target string (command + args). Kept for backward
255
+ // compatibility and display; the proxy uses --target-args for execution.
256
+ let targetCommand = serverConfig.command || '';
257
+ if (originalArgs.length > 0) {
258
+ targetCommand += ' ' + originalArgs.join(' ');
258
259
  }
259
-
260
260
  targetCommand = targetCommand.trim();
261
261
 
262
+ // Authoritative argv passed to the proxy as a JSON array: [command, ...args].
263
+ // This preserves argument boundaries (e.g. paths containing spaces) that the
264
+ // whitespace-joined --target string cannot represent.
265
+ const targetArgv = [serverConfig.command || '', ...originalArgs];
266
+
262
267
  // Create wrapped configuration
263
268
  const wrappedConfig = {
264
269
  command: 'mcp-gov-proxy',
265
270
  args: [
266
271
  '--service', serverName,
267
272
  '--target', targetCommand,
273
+ '--target-args', JSON.stringify(targetArgv),
268
274
  '--rules', rulesPath
269
275
  ],
270
276
  _original: {
271
277
  command: serverConfig.command,
272
- args: serverConfig.args || []
278
+ args: originalArgs
273
279
  }
274
280
  };
275
281
 
@@ -301,35 +307,15 @@ function createBackup(configPath) {
301
307
  const backupPath = `${configPath}.backup-${timestamp}`;
302
308
 
303
309
  copyFileSync(configPath, backupPath);
304
-
305
- return backupPath;
306
- }
307
-
308
- /**
309
- * Wrap unwrapped servers in the config
310
- * @param {Object} config - Full config object with mcpServers
311
- * @param {string[]} unwrappedNames - Names of servers to wrap
312
- * @param {string} rulesPath - Absolute path to rules.json
313
- * @returns {Object} Modified config with wrapped servers
314
- */
315
- function wrapServers(config, unwrappedNames, rulesPath) {
316
- const modifiedConfig = JSON.parse(JSON.stringify(config.rawConfig));
317
-
318
- // Get reference to mcpServers in the modified config
319
- let mcpServers;
320
- if (config.format === 'claude-code') {
321
- mcpServers = modifiedConfig.projects.mcpServers;
322
- } else {
323
- mcpServers = modifiedConfig.mcpServers;
324
- }
325
-
326
- // Wrap each unwrapped server
327
- for (const serverName of unwrappedNames) {
328
- const originalConfig = mcpServers[serverName];
329
- mcpServers[serverName] = wrapServer(serverName, originalConfig, rulesPath);
310
+ // MCP configs frequently embed secrets (API tokens in `env`). Restrict the
311
+ // backup to owner-only so the copy does not widen exposure beyond the original.
312
+ try {
313
+ chmodSync(backupPath, 0o600);
314
+ } catch {
315
+ // Non-POSIX filesystems may not support chmod; the copy still succeeded.
330
316
  }
331
317
 
332
- return modifiedConfig;
318
+ return backupPath;
333
319
  }
334
320
 
335
321
  /**
@@ -426,45 +412,25 @@ function generateDefaultRules(serviceName, tools) {
426
412
  admin: 'deny'
427
413
  };
428
414
 
429
- if (tools.length === 0) {
430
- // No tools discovered, create service-level rules
431
- for (const [operation, permission] of Object.entries(safeDefaults)) {
432
- if (permission === 'deny') {
433
- rules.push({
434
- service: serviceName,
435
- operations: [operation],
436
- permission: permission,
437
- reason: `${operation.charAt(0).toUpperCase() + operation.slice(1)} operations denied by default for safety`
438
- });
439
- }
440
- }
441
- } else {
442
- // Create rules based on discovered tools
443
- const toolsByOperation = { read: [], write: [], delete: [], execute: [], admin: [] };
444
-
445
- tools.forEach(toolName => {
446
- const operation = detectOperation(toolName);
447
- if (toolsByOperation[operation]) {
448
- toolsByOperation[operation].push(toolName);
449
- }
450
- });
451
-
452
- // Create rules for each operation type that has tools
453
- for (const [operation, permission] of Object.entries(safeDefaults)) {
454
- if (toolsByOperation[operation].length > 0) {
455
- const rule = {
456
- service: serviceName,
457
- operations: [operation],
458
- permission: permission
459
- };
460
-
461
- if (permission === 'deny') {
462
- rule.reason = `${operation.charAt(0).toUpperCase() + operation.slice(1)} operations denied by default for safety`;
463
- }
415
+ // Always emit an explicit rule for every operation type, regardless of which
416
+ // tools were discovered. This keeps the generated rule set complete so a
417
+ // `defaultPolicy: "deny"` file never accidentally blocks ordinary read/write
418
+ // traffic, and it states the intended posture explicitly rather than relying
419
+ // on a fallthrough default. Discovered tool names are still used (below, by
420
+ // the caller's logging) but do not change the safe-default policy.
421
+ void tools;
422
+ for (const [operation, permission] of Object.entries(safeDefaults)) {
423
+ const rule = {
424
+ service: serviceName,
425
+ operations: [operation],
426
+ permission: permission
427
+ };
464
428
 
465
- rules.push(rule);
466
- }
429
+ if (permission === 'deny') {
430
+ rule.reason = `${operation.charAt(0).toUpperCase() + operation.slice(1)} operations denied by default for safety`;
467
431
  }
432
+
433
+ rules.push(rule);
468
434
  }
469
435
 
470
436
  return rules;
@@ -515,10 +481,13 @@ async function ensureRulesExist(rulesPath, mcpServers) {
515
481
  console.log(` ✓ Added ${rules.length} rule(s) for ${serverName}`);
516
482
  }
517
483
 
518
- // Merge with existing rules
484
+ // Merge with existing rules. Preserve the existing file's defaultPolicy
485
+ // verbatim (including its absence) so updating an existing install never
486
+ // silently changes its fail-open/fail-closed posture.
519
487
  const mergedRules = {
520
488
  _comment: 'Auto-generated governance rules. Edit as needed.',
521
489
  _location: rulesPath,
490
+ ...(existingRules.defaultPolicy ? { defaultPolicy: existingRules.defaultPolicy } : {}),
522
491
  rules: [...existingRules.rules, ...newRules]
523
492
  };
524
493
 
@@ -540,6 +509,7 @@ async function ensureRulesExist(rulesPath, mcpServers) {
540
509
  const emptyRules = {
541
510
  _comment: 'Auto-generated governance rules. Add servers and run again.',
542
511
  _location: rulesPath,
512
+ defaultPolicy: 'deny',
543
513
  rules: []
544
514
  };
545
515
  writeFileSync(rulesPath, JSON.stringify(emptyRules, null, 2) + '\n');
@@ -563,10 +533,14 @@ async function ensureRulesExist(rulesPath, mcpServers) {
563
533
  }
564
534
  }
565
535
 
566
- // Create rules file
536
+ // Create rules file. New installs are fail-closed by default: anything not
537
+ // matched by an explicit rule below is denied (the generated rules cover
538
+ // read/write/delete/execute/admin for every service, so normal traffic is
539
+ // unaffected). Set "defaultPolicy": "allow" to restore the permissive mode.
567
540
  const rulesData = {
568
541
  _comment: 'Auto-generated governance rules. Edit as needed.',
569
542
  _location: rulesPath,
543
+ defaultPolicy: 'deny',
570
544
  rules: allRules
571
545
  };
572
546
 
package/bin/mcp-gov.js CHANGED
@@ -92,7 +92,8 @@ async function handleWrap() {
92
92
  continue;
93
93
  }
94
94
 
95
- path = input.startsWith('~') ? input.replace('~', homedir()) : input;
95
+ // Expand a leading ~ or ~/ only; do not touch a ~ elsewhere in the path.
96
+ path = input.replace(/^~(?=$|\/)/, homedir());
96
97
 
97
98
  if (existsSync(path)) {
98
99
  break;
@@ -124,7 +125,8 @@ async function handleUnwrap() {
124
125
  continue;
125
126
  }
126
127
 
127
- path = input.startsWith('~') ? input.replace('~', homedir()) : input;
128
+ // Expand a leading ~ or ~/ only; do not touch a ~ elsewhere in the path.
129
+ path = input.replace(/^~(?=$|\/)/, homedir());
128
130
 
129
131
  if (existsSync(path)) {
130
132
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-gov",
3
- "version": "1.3.1",
3
+ "version": "2.0.0",
4
4
  "description": "MCP Governance System - Permission control and audit logging for Model Context Protocol servers",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -12,8 +12,9 @@
12
12
  },
13
13
  "scripts": {
14
14
  "example:github": "node examples/github/server.js",
15
- "test": "node test/proxy.test.js && node test/wrapper.test.js && node test/unwrap.test.js && node test/platform.test.js && node test/integration.test.js && node test/multi-service.test.js && node test/performance.test.js && node test/service-param.test.js",
15
+ "test": "node test/rules.test.js && node test/proxy.test.js && node test/wrapper.test.js && node test/unwrap.test.js && node test/platform.test.js && node test/integration.test.js && node test/multi-service.test.js && node test/performance.test.js && node test/service-param.test.js",
16
16
  "test:all": "npm test",
17
+ "test:rules": "node test/rules.test.js",
17
18
  "test:proxy": "node test/proxy.test.js",
18
19
  "test:wrapper": "node test/wrapper.test.js",
19
20
  "test:unwrap": "node test/unwrap.test.js",
@@ -34,7 +35,7 @@
34
35
  ],
35
36
  "repository": {
36
37
  "type": "git",
37
- "url": "git+https://github.com/amrhas82/mcp-gov.git"
38
+ "url": "git+https://github.com/hamr0/mcp-gov.git"
38
39
  },
39
40
  "keywords": [
40
41
  "mcp",
@@ -42,14 +43,18 @@
42
43
  "permissions",
43
44
  "audit"
44
45
  ],
45
- "author": "amrhas82",
46
- "license": "MIT",
46
+ "author": "hamr0",
47
+ "license": "Apache-2.0",
48
+ "homepage": "https://github.com/hamr0/mcp-gov#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/hamr0/mcp-gov/issues"
51
+ },
47
52
  "dependencies": {
48
- "@modelcontextprotocol/sdk": "^0.5.0",
49
- "axios": "^1.6.0",
50
- "dotenv": "^16.0.0"
53
+ "@modelcontextprotocol/sdk": "^0.5.0"
51
54
  },
52
55
  "devDependencies": {
53
- "@types/node": "^25.0.10"
56
+ "@types/node": "^25.0.10",
57
+ "axios": "^1.12.0",
58
+ "dotenv": "^16.0.0"
54
59
  }
55
60
  }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { parseToolName } from './operation-detector.js';
10
+ import { normalizeRules, isAllowed } from './rules.js';
10
11
 
11
12
  /**
12
13
  * @typedef {Object} ServerConfig
@@ -40,6 +41,13 @@ export class GovernedMCPServer {
40
41
  constructor(config, rules = {}) {
41
42
  this.config = config;
42
43
  this.rules = rules;
44
+ // Normalize whatever rule shape was passed (nested-map, legacy object, or
45
+ // array) into one canonical form, sharing the same matcher as the proxy.
46
+ // defaultPolicy is read from the rules object first (the documented
47
+ // location), falling back to the config object; defaults to 'allow' for
48
+ // backward compatibility.
49
+ this.normalizedRules = normalizeRules(rules, config.defaultPolicy);
50
+ this.defaultPolicy = this.normalizedRules.defaultPolicy;
43
51
  this.tools = new Map(); // Store tool definitions and handlers
44
52
  this.server = new Server(
45
53
  {
@@ -137,21 +145,7 @@ export class GovernedMCPServer {
137
145
  */
138
146
  checkPermission(toolName) {
139
147
  const { service, operation } = parseToolName(toolName);
140
-
141
- // Check if service has rules
142
- if (!this.rules[service]) {
143
- // Default to 'allow' if no rule exists (permissive for POC)
144
- return true;
145
- }
146
-
147
- // Check operation permission
148
- const permission = this.rules[service][operation];
149
- if (permission === 'deny') {
150
- return false;
151
- }
152
-
153
- // Default to allow
154
- return true;
148
+ return isAllowed(this.normalizedRules, service, operation);
155
149
  }
156
150
 
157
151
  /**
package/src/rules.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Rules loading and normalization.
3
+ *
4
+ * Historically three rule shapes existed across the proxy and the library:
5
+ * 1. Array format (generated by mcp-gov-wrap):
6
+ * { defaultPolicy?, rules: [{ service, operations: [...], permission }] }
7
+ * 2. Legacy object format:
8
+ * { defaultPolicy?, services: { svc: { operations: { op: 'allow'|'deny' } } } }
9
+ * 3. SDK nested-map (GovernedMCPServer / examples):
10
+ * { svc: { op: 'allow'|'deny' }, defaultPolicy? }
11
+ *
12
+ * `normalizeRules` accepts any of these and produces a single canonical form,
13
+ * so callers share one matcher (`isAllowed`) instead of duplicating per-format
14
+ * logic. `defaultPolicy` is preserved (default 'allow' for backward compat).
15
+ */
16
+
17
+ /**
18
+ * @typedef {'allow'|'deny'} Permission
19
+ * @typedef {{ defaultPolicy: Permission, services: Record<string, Record<string, Permission>> }} NormalizedRules
20
+ */
21
+
22
+ /**
23
+ * Top-level keys that are metadata, never service names, in the nested-map
24
+ * shape. `defaultPolicy` is our keyword and `_*` is the comment convention;
25
+ * `services`/`rules` are NOT reserved here because format detection already
26
+ * routes the structural variants away, so a nested-map service may legitimately
27
+ * be named "services" or "rules" without being silently dropped.
28
+ */
29
+ function isReservedKey(key) {
30
+ return key === 'defaultPolicy' || key.startsWith('_');
31
+ }
32
+
33
+ /**
34
+ * True only for the legacy { svc: { operations: { op: perm } } } shape, i.e. a
35
+ * `services` object whose entries wrap an `operations` map. This prevents a
36
+ * nested-map rules object with a service literally named "services" (e.g.
37
+ * { services: { read: "deny" } }) from being misdetected as legacy and dropped.
38
+ * @param {any} services
39
+ * @returns {boolean}
40
+ */
41
+ function looksLikeLegacy(services) {
42
+ if (!services || typeof services !== 'object' || Array.isArray(services)) return false;
43
+ return Object.values(services).some(
44
+ (cfg) => cfg && typeof cfg === 'object' && cfg.operations && typeof cfg.operations === 'object'
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Normalize any supported rules shape into a canonical
50
+ * { defaultPolicy, services: { svc: { op: permission } } } form.
51
+ * @param {any} raw - Parsed rules object (any supported format)
52
+ * @param {Permission} [fallbackDefaultPolicy] - Used when `raw` has no defaultPolicy
53
+ * @returns {NormalizedRules}
54
+ */
55
+ export function normalizeRules(raw, fallbackDefaultPolicy) {
56
+ /** @type {NormalizedRules} */
57
+ const out = { defaultPolicy: 'allow', services: {} };
58
+
59
+ if (!raw || typeof raw !== 'object') {
60
+ out.defaultPolicy = fallbackDefaultPolicy === 'deny' ? 'deny' : 'allow';
61
+ return out;
62
+ }
63
+
64
+ // Rules-level defaultPolicy takes precedence over any caller fallback.
65
+ const policy = raw.defaultPolicy || fallbackDefaultPolicy;
66
+ out.defaultPolicy = policy === 'deny' ? 'deny' : 'allow';
67
+
68
+ const setOp = (service, op, permission) => {
69
+ if (!service || (permission !== 'allow' && permission !== 'deny')) return;
70
+ if (!out.services[service]) out.services[service] = {};
71
+ out.services[service][op] = permission;
72
+ };
73
+
74
+ if (Array.isArray(raw.rules)) {
75
+ // Format 1: array of { service, operations[], permission }
76
+ for (const rule of raw.rules) {
77
+ if (!rule || !rule.service || !Array.isArray(rule.operations)) continue;
78
+ for (const op of rule.operations) setOp(rule.service, op, rule.permission);
79
+ }
80
+ } else if (looksLikeLegacy(raw.services)) {
81
+ // Format 2: legacy { services: { svc: { operations: { op: perm } } } }
82
+ for (const [service, cfg] of Object.entries(raw.services)) {
83
+ const ops = cfg && cfg.operations;
84
+ if (ops && typeof ops === 'object') {
85
+ for (const [op, perm] of Object.entries(ops)) setOp(service, op, perm);
86
+ }
87
+ }
88
+ } else {
89
+ // Format 3: SDK nested-map { svc: { op: perm } } (skip metadata keys)
90
+ for (const [service, cfg] of Object.entries(raw)) {
91
+ if (isReservedKey(service)) continue;
92
+ if (cfg && typeof cfg === 'object') {
93
+ for (const [op, perm] of Object.entries(cfg)) setOp(service, op, perm);
94
+ }
95
+ }
96
+ }
97
+
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Decide whether an operation is permitted against normalized rules.
103
+ * An explicit allow/deny for the service+operation wins; otherwise the
104
+ * normalized defaultPolicy applies (allow unless set to 'deny').
105
+ * @param {NormalizedRules} normalized
106
+ * @param {string} service
107
+ * @param {string} operation
108
+ * @returns {boolean}
109
+ */
110
+ export function isAllowed(normalized, service, operation) {
111
+ const svc = normalized.services[service];
112
+ const permission = svc && svc[operation];
113
+ if (permission === 'allow') return true;
114
+ if (permission === 'deny') return false;
115
+ return normalized.defaultPolicy !== 'deny';
116
+ }