@parasraju/plumber-mcp 1.0.1 → 1.2.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/README.md CHANGED
@@ -83,15 +83,15 @@ Add to `.vscode/mcp.json`:
83
83
 
84
84
  | Tool | Description |
85
85
  | --- | --- |
86
- | `plumber_scan` | Scan a local directory or file. Returns a score, grade, file count, and one line per issue with its fix. |
87
- | `plumber_list_rules` | List all 21 inspection rules with id, severity, title, and fix. |
88
- | `plumber_scan_text` | Scan a pasted code or config snippet. `filename` is optional and only selects the applicable rules. |
86
+ | `plumber_scan` | Scan a local directory or file. Returns a score, grade, file count, and one line per issue with its fix. Optional `rulesFile`, or `.plumber-rules.json` in the target, adds a rule pack. |
87
+ | `plumber_list_rules` | List all 26 inspection rules with id, severity, title, and fix. Optional `rulesFile` appends pack rules marked `source: pack`. |
88
+ | `plumber_scan_text` | Scan a pasted code or config snippet. `filename` is optional and only selects the applicable rules. Optional `rulesFile` adds a rule pack. |
89
89
 
90
90
  Tool inputs are deliberately small so they cost few tokens:
91
91
 
92
- - `plumber_scan`: `target` (string, default `"."`)
93
- - `plumber_list_rules`: none
94
- - `plumber_scan_text`: `text` (string, required), `filename` (string, optional)
92
+ - `plumber_scan`: `target` (string, default `"."`), `rulesFile` (string, optional)
93
+ - `plumber_list_rules`: `rulesFile` (string, optional)
94
+ - `plumber_scan_text`: `text` (string, required), `filename` (string, optional), `rulesFile` (string, optional)
95
95
 
96
96
  ## Example session
97
97
 
@@ -103,6 +103,94 @@ Tool inputs are deliberately small so they cost few tokens:
103
103
  >
104
104
  > **Agent:** I moved the key into `.env`, replaced it with `process.env.OPENAI_API_KEY`, and added `.env` to `.gitignore`. You should revoke the exposed key.
105
105
 
106
+ ### Scan GitHub Actions workflows
107
+
108
+ `plumber_scan` reads `.github/workflows/*.yml` and `*.yaml`. `plumber_scan_text` applies the same rules when the filename is under a `workflows` path, or when the snippet parses as a workflow (`on:` and `jobs:`).
109
+
110
+ - `sec/pull-request-target` (CRITICAL) — a `pull_request_target` workflow checks out untrusted pull request code or grants write permissions.
111
+ - `sec/expression-injection` (CRITICAL) — `${{ github.event.* }}`, `${{ github.head_ref }}`, or `${{ inputs.* }}` is interpolated into a `run` script.
112
+ - `pipe/action-unpinned` (MEDIUM) — a third-party `uses:` action is pinned to a branch or version tag instead of a full commit SHA.
113
+ - `sec/job-permissions` (HIGH) — a job that pushes, releases, or deploys has no permissions block, uses `write-all`, or sets `contents: write` where a narrower scope would do.
114
+ - `sec/secret-to-untrusted` (HIGH) — a secret is passed through `env` or an argument to a third-party action.
115
+
116
+ > **Agent:** *(calls `plumber_scan` with `target: "."` on a repo whose `.github/workflows/pr.yml` checks out the pull request head from `pull_request_target`)*
117
+ >
118
+ > `- [CRITICAL] Untrusted pull_request_target Checkout or Write Permission (Use pull_request, or check out the base ref read-only with no secrets.) — .github/workflows/pr.yml:9`
119
+
120
+ ### Workflow YAML rules
121
+
122
+ - `sec/pull-request-target` [CRITICAL] Untrusted pull_request_target Checkout or Write Permission (Use pull_request, or check out the base ref read-only with no secrets.)
123
+ - `sec/expression-injection` [CRITICAL] Expression Injection in Workflow run Step (Pass the value through an env: variable and reference that variable in the script.)
124
+ - `pipe/action-unpinned` [MEDIUM] Unpinned Third-Party Action (Pin the action to a full commit SHA.)
125
+ - `sec/job-permissions` [HIGH] Job Writes Without Least-Privilege Permissions (Set least-privilege permissions on the job.)
126
+ - `sec/secret-to-untrusted` [HIGH] Secret Passed to Untrusted Step (Scope the secret to the trusted step that needs it.)
127
+
128
+ ### Custom rule packs — bring your own rules
129
+
130
+ Bring rule packs when you need custom rules the built-in set does not ship: an internal secret format, a banned pattern, or a house naming rule. A pack is one local JSON file. This release does not read YAML packs, and it does not download a pack from a URL.
131
+
132
+ The file is a top-level object `{ "rules": [...] }`. Each rule uses these fields:
133
+
134
+ - `id` (string, required) — must start with `custom/`, for example `custom/no-internal-api-key`. Any other id is rejected. An id that matches a built-in rule, or another rule in the same pack, is rejected. The built-in rule is left unchanged.
135
+ - `severity` (string, required) — one of `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`. Anything else is rejected.
136
+ - `title` (string, required) — short name, for example `Internal API key format`.
137
+ - `pattern` (string, required) — a JavaScript regular expression, matched against the file text. A pattern that does not compile rejects that rule.
138
+ - `files` (array of glob strings, optional) — for example `["**/*.js", "**/*.ts"]`. The rule runs only on files that match one of these globs. Omit it to use every file Plumber already scans. Globs support `*`, `?`, and `**`. `*` does not cross a `/`; use `**/*.js` to match JavaScript in any directory.
139
+ - `fix` (string, required) — the plain-English fix, in the same voice as the built-in rules. A rule with no fix is rejected.
140
+
141
+ On `plumber_scan`, globs only filter files the scanner already opens: `.py`, `.js`, `.jsx`, `.ts`, `.tsx`, `.html`, `.htm`, `.css`, `.scss`, `.env*`, and GitHub Actions workflow YAML. `plumber_scan_text` applies the same globs to the `filename` you pass.
142
+
143
+ A bad rule is dropped. The tool result includes a `Pack error:` line that names the rule and the problem (invalid JSON names the pack file, because there is no rule yet). The 26 built-in rules still run. A broken pack does not wipe the report and does not stop the server.
144
+
145
+ One pack is loaded, in this order:
146
+
147
+ 1. `rulesFile`, when the tool call includes it. A relative path is resolved from the server's working directory.
148
+ 2. Auto-load, only for `plumber_scan`, and only when `rulesFile` is omitted. Plumber reads `.plumber-rules.json` in the directory passed as `target`. A file target uses the directory that contains that file. Parent folders and subfolders are not searched. `plumber_scan_text` and `plumber_list_rules` do not auto-load.
149
+ 3. When both are present, `rulesFile` is the pack that runs. The two files are not merged.
150
+
151
+ The scan result names the pack on one line: the file path and how many pack rules loaded, for example `Pack: /repo/.plumber-rules.json (2 pack rules loaded)`.
152
+
153
+ `plumber_list_rules` with no `rulesFile` returns the 26 built-in rules. With `rulesFile`, the pack rules follow the built-ins and each one ends with `source: pack`.
154
+
155
+ Save this file as `.plumber-rules.json` in the directory you scan, or pass the path as `rulesFile`. The same example ships at `examples/custom-rules.example.json`.
156
+
157
+ ```json
158
+ {
159
+ "rules": [
160
+ {
161
+ "id": "custom/no-internal-api-key",
162
+ "severity": "CRITICAL",
163
+ "title": "Internal API key format",
164
+ "pattern": "\\bINT_[A-Z0-9]{16}\\b",
165
+ "files": ["**/*.js", "**/*.ts"],
166
+ "fix": "Move the internal key to an environment variable and rotate it."
167
+ },
168
+ {
169
+ "id": "custom/no-banned-marker",
170
+ "severity": "MEDIUM",
171
+ "title": "Banned ship marker",
172
+ "pattern": "\\bDO_NOT_SHIP\\b",
173
+ "files": ["**/*.js", "**/*.py"],
174
+ "fix": "Remove the banned marker before committing this file."
175
+ }
176
+ ]
177
+ }
178
+ ```
179
+
180
+ Three ways to run that pack:
181
+
182
+ 1. Auto-load: write it to `.plumber-rules.json`, then call `plumber_scan` with `{ "target": "." }`.
183
+ 2. `rulesFile` on `plumber_scan`: `{ "target": ".", "rulesFile": "examples/custom-rules.example.json" }`. This is the pack that runs when `.plumber-rules.json` is also present.
184
+ 3. `rulesFile` on `plumber_scan_text`: `{ "text": "const internal = \"INT_ABCDEFGHIJ123456\";\n", "filename": "src/config.js", "rulesFile": "examples/custom-rules.example.json" }`. A snippet that does not match the pattern produces no pack finding.
185
+
186
+ `src/config.js` containing `INT_ABCDEFGHIJ123456` then reports:
187
+
188
+ ```
189
+ - [CRITICAL] Internal API key format (Move the internal key to an environment variable and rotate it.) — src/config.js:1
190
+ ```
191
+
192
+ The secret-format rule above does not check Python files. The banned-marker rule does, because its `files` list says so.
193
+
106
194
  ## Links
107
195
 
108
196
  - CLI: [plumber-linter](https://www.npmjs.com/package/plumber-linter)
@@ -112,6 +200,12 @@ Tool inputs are deliberately small so they cost few tokens:
112
200
 
113
201
  ## Changelog
114
202
 
203
+ ## 1.2.0
204
+ - Custom rule packs: load your own rules from `.plumber-rules.json` or a `rulesFile`, ids under `custom/`.
205
+
206
+ ## 1.1.0
207
+ - Workflow YAML rules: sec/pull-request-target, sec/expression-injection, pipe/action-unpinned, sec/job-permissions, sec/secret-to-untrusted.
208
+
115
209
  ## 1.0.1
116
210
  - Fix false-positive test path matching that skipped secrets in files containing "test" or "spec" substrings (e.g. `latest.js`, `inspector.js`).
117
211
  - Fix directory scan skipping target path when root directory name matches skip list or begins with a dot (e.g. `dist`, `.hidden`).
@@ -5,9 +5,13 @@
5
5
  *
6
6
  * Tools:
7
7
  * plumber_scan scan a local directory or file
8
- * plumber_list_rules list the 21 inspection rules
8
+ * plumber_list_rules list every inspection rule
9
9
  * plumber_scan_text scan a pasted snippet
10
10
  *
11
+ * Each tool accepts an optional rulesFile path to one local JSON rule pack.
12
+ * plumber_scan also reads .plumber-rules.json in the target when rulesFile is omitted.
13
+ * Packs are never fetched from a URL.
14
+ *
11
15
  * Only JSON-RPC goes to stdout. Diagnostics go to stderr.
12
16
  */
13
17
 
@@ -21,7 +25,7 @@ const {
21
25
  ErrorCode,
22
26
  McpError
23
27
  } = require('@modelcontextprotocol/sdk/types.js');
24
- const { RULES, SCANNABLE_EXTS, scanLocalDirectory, scanText } = require('../src/engine.js');
28
+ const { RULES, SCANNABLE_EXTS, scanLocalDirectory, scanText, loadRulePack, resolveScanPack } = require('../src/engine.js');
25
29
 
26
30
  // v1.0.1 fix: read the version from package.json so serverInfo never drifts.
27
31
  // Repro (v1.0.0): initialize -> serverInfo.version was hardcoded to "1.0.0".
@@ -34,7 +38,8 @@ const TOOLS = [
34
38
  inputSchema: {
35
39
  type: 'object',
36
40
  properties: {
37
- target: { type: 'string', description: 'Directory or file path to scan (default ".")' }
41
+ target: { type: 'string', description: 'Directory or file path to scan (default ".")' },
42
+ rulesFile: { type: 'string', description: 'Optional path to a local JSON rule pack. When omitted, .plumber-rules.json is loaded from the scan target if that file exists.' }
38
43
  },
39
44
  additionalProperties: false
40
45
  }
@@ -44,7 +49,9 @@ const TOOLS = [
44
49
  description: 'List all Plumber inspection rules with severity, title, and recommended fix.',
45
50
  inputSchema: {
46
51
  type: 'object',
47
- properties: {},
52
+ properties: {
53
+ rulesFile: { type: 'string', description: 'Optional path to a local JSON rule pack. Pack rules are listed after the built-in rules and marked as custom.' }
54
+ },
48
55
  additionalProperties: false
49
56
  }
50
57
  },
@@ -55,7 +62,8 @@ const TOOLS = [
55
62
  type: 'object',
56
63
  properties: {
57
64
  text: { type: 'string', description: 'Code or config snippet to scan' },
58
- filename: { type: 'string', description: 'Optional filename used to select applicable rules (e.g. app.js)' }
65
+ filename: { type: 'string', description: 'Optional filename used to select applicable rules (e.g. app.js)' },
66
+ rulesFile: { type: 'string', description: 'Optional path to a local JSON rule pack applied to this snippet. Pasted snippets do not auto-load a pack.' }
59
67
  },
60
68
  required: ['text'],
61
69
  additionalProperties: false
@@ -63,22 +71,47 @@ const TOOLS = [
63
71
  }
64
72
  ];
65
73
 
74
+ function packLoadedLine(pack) {
75
+ const count = Array.isArray(pack.rules) ? pack.rules.length : 0;
76
+ const label = count === 1 ? 'pack rule' : 'pack rules';
77
+ return `Pack: ${pack.path} (${count} ${label} loaded)`;
78
+ }
79
+
80
+ function formatPackBanner(pack) {
81
+ if (!pack) return '';
82
+ const lines = [packLoadedLine(pack)];
83
+ const errors = Array.isArray(pack.errors) ? pack.errors : [];
84
+ for (let i = 0; i < errors.length; i++) {
85
+ lines.push(`Pack error: ${errors[i]}`);
86
+ }
87
+ return lines.join('\n');
88
+ }
89
+
90
+ function formatListedRule(rule) {
91
+ const severity = rule.sev || rule.severity;
92
+ const line = `${rule.id} [${severity}] ${rule.title} (${rule.fix})`;
93
+ if (rule.source === 'pack') return `${line} source: pack`;
94
+ return line;
95
+ }
96
+
66
97
  function formatReport(report) {
67
98
  const header = [
68
99
  `Plumber Health Report for ${report.repo_name}`,
69
100
  report.total_files === 0 ? 'Score: n/a' : `Score: ${report.score}/100 (Grade ${report.grade})`,
70
101
  `Files: ${report.total_files} | Issues: ${report.issues.length}`
71
102
  ].join('\n');
103
+ const banner = formatPackBanner(report.pack);
104
+ const top = banner ? `${header}\n${banner}` : header;
72
105
 
73
106
  // v1.0.1 fix: do not report "No issues found." when nothing was scanned.
74
107
  // Repro (v1.0.0): plumber_scan { target: ".github/workflows/ci.yml" } (or a
75
108
  // Dockerfile, or an empty dir) -> "Score: 100/100 (Grade A) ... No issues found."
76
109
  if (report.total_files === 0) {
77
- return `${header}\n\nNo scannable files found, so nothing was checked. Supported: ${SCANNABLE_EXTS.join(' ')} and .env* files.`;
110
+ return `${top}\n\nNo scannable files found, so nothing was checked. Supported: ${SCANNABLE_EXTS.join(' ')} and .env* files.`;
78
111
  }
79
112
 
80
113
  if (report.issues.length === 0) {
81
- return `${header}\n\nNo issues found.`;
114
+ return `${top}\n\nNo issues found.`;
82
115
  }
83
116
 
84
117
  const locations = new Map();
@@ -94,7 +127,7 @@ function formatReport(report) {
94
127
  return `- [${issue.severity}] ${issue.title} (${issue.fix})${suffix}`;
95
128
  });
96
129
 
97
- return `${header}\n\n${lines.join('\n')}`;
130
+ return `${top}\n\n${lines.join('\n')}`;
98
131
  }
99
132
 
100
133
  function requireString(value, name) {
@@ -114,15 +147,29 @@ function requireText(text) {
114
147
 
115
148
  function runScanTool(args) {
116
149
  const target = requireString(args.target, 'target') || '.';
150
+ const rulesFile = requireString(args.rulesFile, 'rulesFile');
117
151
  const absolute = path.resolve(target);
118
152
 
119
153
  if (!fs.existsSync(absolute)) {
120
154
  throw new McpError(ErrorCode.InvalidParams, `Target path does not exist: ${absolute}`);
121
155
  }
122
156
 
157
+ let pack = null;
123
158
  try {
124
- return formatReport(scanLocalDirectory(absolute));
159
+ pack = resolveScanPack(absolute, rulesFile);
125
160
  } catch (err) {
161
+ pack = {
162
+ path: rulesFile || absolute,
163
+ rules: [],
164
+ errors: [`Pack failed to load: ${err.message}`],
165
+ broken: true
166
+ };
167
+ }
168
+
169
+ try {
170
+ return formatReport(scanLocalDirectory(absolute, pack ? { pack } : undefined));
171
+ } catch (err) {
172
+ if (err instanceof McpError) throw err;
126
173
  throw new McpError(ErrorCode.InternalError, `Scan failed: ${err.message}`);
127
174
  }
128
175
  }
@@ -130,16 +177,55 @@ function runScanTool(args) {
130
177
  function runScanTextTool(args) {
131
178
  const text = requireText(args.text);
132
179
  const filename = requireString(args.filename, 'filename');
180
+ const rulesFile = requireString(args.rulesFile, 'rulesFile');
181
+ let pack = null;
182
+ if (rulesFile) {
183
+ try {
184
+ pack = loadRulePack(rulesFile);
185
+ } catch (err) {
186
+ pack = {
187
+ path: rulesFile,
188
+ rules: [],
189
+ errors: [`Pack ${rulesFile} failed to load: ${err.message}`],
190
+ broken: true
191
+ };
192
+ }
193
+ }
133
194
 
134
195
  try {
135
- return formatReport(scanText(text, filename));
196
+ return formatReport(scanText(text, filename, pack ? { pack } : undefined));
136
197
  } catch (err) {
198
+ if (err instanceof McpError) throw err;
137
199
  throw new McpError(ErrorCode.InternalError, `Scan failed: ${err.message}`);
138
200
  }
139
201
  }
140
202
 
141
- function runListRulesTool() {
142
- return RULES.map((rule) => `${rule.id} [${rule.sev}] ${rule.title} (${rule.fix})`).join('\n');
203
+ function runListRulesTool(args) {
204
+ const rulesFile = requireString(args && args.rulesFile, 'rulesFile');
205
+ let pack = null;
206
+ if (rulesFile) {
207
+ try {
208
+ pack = loadRulePack(rulesFile);
209
+ } catch (err) {
210
+ pack = {
211
+ path: rulesFile,
212
+ rules: [],
213
+ errors: [`Pack ${rulesFile} failed to load: ${err.message}`],
214
+ broken: true
215
+ };
216
+ }
217
+ }
218
+ const lines = RULES.map((rule) => formatListedRule(rule));
219
+ if (!pack) return lines.join('\n');
220
+ for (let i = 0; i < pack.rules.length; i++) {
221
+ lines.push(formatListedRule(pack.rules[i]));
222
+ }
223
+ lines.push(packLoadedLine(pack));
224
+ const errors = pack.errors || [];
225
+ for (let i = 0; i < errors.length; i++) {
226
+ lines.push(`Pack error: ${errors[i]}`);
227
+ }
228
+ return lines.join('\n');
143
229
  }
144
230
 
145
231
  async function main() {
@@ -155,7 +241,7 @@ async function main() {
155
241
  if (name === 'plumber_scan') {
156
242
  text = runScanTool(args);
157
243
  } else if (name === 'plumber_list_rules') {
158
- text = runListRulesTool();
244
+ text = runListRulesTool(args);
159
245
  } else if (name === 'plumber_scan_text') {
160
246
  text = runScanTextTool(args);
161
247
  } else {
@@ -0,0 +1,20 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "id": "custom/no-internal-api-key",
5
+ "severity": "CRITICAL",
6
+ "title": "Internal API key format",
7
+ "pattern": "\\bINT_[A-Z0-9]{16}\\b",
8
+ "files": ["**/*.js", "**/*.ts"],
9
+ "fix": "Move the internal key to an environment variable and rotate it."
10
+ },
11
+ {
12
+ "id": "custom/no-banned-marker",
13
+ "severity": "MEDIUM",
14
+ "title": "Banned ship marker",
15
+ "pattern": "\\bDO_NOT_SHIP\\b",
16
+ "files": ["**/*.js", "**/*.py"],
17
+ "fix": "Remove the banned marker before committing this file."
18
+ }
19
+ ]
20
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@parasraju/plumber-mcp",
3
- "version": "1.0.1",
4
- "description": "Plumber as an MCP server — let your AI coding agent scan repos for leaked secrets, dead code, and AI slop, with fixes in plain English.",
3
+ "version": "1.2.0",
4
+ "description": "Plumber as an MCP server — let your AI coding agent scan repos and GitHub Actions workflows for leaked secrets, dead code, and AI slop, with fixes in plain English. Add your own checks with custom rule packs.",
5
5
  "main": "src/engine.js",
6
6
  "bin": {
7
7
  "plumber-mcp": "bin/plumber-mcp.js"
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "bin",
17
17
  "src",
18
+ "examples",
18
19
  "README.md"
19
20
  ],
20
21
  "keywords": [
@@ -27,7 +28,13 @@
27
28
  "security",
28
29
  "secrets-detection",
29
30
  "code-quality",
30
- "linter"
31
+ "linter",
32
+ "github-actions",
33
+ "ci-cd",
34
+ "workflow",
35
+ "yaml",
36
+ "custom-rules",
37
+ "rule-packs"
31
38
  ],
32
39
  "repository": {
33
40
  "type": "git",
@@ -40,6 +47,7 @@
40
47
  "license": "MIT",
41
48
  "author": "Paras Raju",
42
49
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.32.2"
50
+ "@modelcontextprotocol/sdk": "^1.32.2",
51
+ "js-yaml": "^4.3.2"
44
52
  }
45
53
  }